Skip to content

Commit 79e925e

Browse files
committed
feat(review): add fast open-PR reconciliation to catch silently-lost webhooks (#3812)
Two contributor PRs had zero trace anywhere (no audit event, no local row) for roughly two hours before a manually-forced sweep's open-PR discovery side effect happened to surface them. The only organic re-discovery path skips any repo whose sync is "fresh" (up to 6 hours), so a silently-lost "PR opened" webhook could go unnoticed far too long. Add a dedicated, much tighter reconciliation (flag-gated, default OFF, every 10 minutes): a cheap list-only GitHub read diffed against the local pull_requests table for every acting-autonomy repo. On divergence, immediately catch up each missing PR number through the exact same pipeline a real webhook would have used -- fetch full details, upsert, and enqueue a normal regate -- instead of waiting on the opportunistic 6-hour backfill. Fails safe at every level: a catch-up failure, a per-repo scan error, and a total scan failure are all logged and swallowed rather than lost or thrown into the queue.
1 parent 7d145f0 commit 79e925e

13 files changed

Lines changed: 526 additions & 2 deletions

src/env.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ declare global {
241241
* byte-identical to today. NOTE: this is read-only OBSERVABILITY only; the auto-tune / config-mutation
242242
* self-improve loop (src/review/auto-apply.ts) is deliberately NOT wired here — see ops-wire.ts. */
243243
GITTENSORY_REVIEW_OPS?: string;
244+
/** Self-heal: when truthy, a short-interval cron list-diffs GitHub's open PR numbers against the local
245+
* table for every acting-autonomy repo and catches up (fetch + upsert + regate) any PR number GitHub has
246+
* that the local table doesn't — a silently-lost "opened" webhook, caught within minutes instead of the
247+
* 6-hour backfillRegisteredRepositories freshness window. Default OFF — unset/false means the cron tick
248+
* enqueues NO reconciliation job, so the worker is byte-identical to today. */
249+
GITTENSORY_PR_RECONCILIATION?: string;
244250
/** Convergence (RAG retrieval): when truthy, the AI reviewer prompt gains a RELEVANT EXISTING CODE / DOCS
245251
* section — at review time the codebase vector index is queried for code/docs semantically related to the
246252
* PR's changed files (callers, related modules, existing conventions) and appended as additive reference

src/github/backfill.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3132,6 +3132,52 @@ export async function fetchLivePullRequest(
31323132
return result.status === "ok" ? result.data : undefined;
31333133
}
31343134

3135+
// Bounded at 1000 open PRs (10 pages of 100) — far beyond any real repo's open-PR count; a pathological repo
3136+
// can't spin this loop forever.
3137+
const RECONCILE_OPEN_PRS_MAX_PAGES = 10;
3138+
3139+
export type OpenPrReconciliationResult = {
3140+
repoFullName: string;
3141+
remoteOpenCount: number;
3142+
localOpenCount: number;
3143+
/** PR numbers GitHub reports open that have NO local pull_requests row at all — a webhook silently lost. */
3144+
missingNumbers: number[];
3145+
};
3146+
3147+
/**
3148+
* Fast open-PR reconciliation (#audit-open-pr-reconciliation): a CHEAP list-only GitHub read (PR numbers only,
3149+
* via the Link-header-paginated `/pulls?state=open` list — never a per-PR detail fetch) diffed against the
3150+
* local `pull_requests` table. Exists to catch a silently-lost "PR opened" webhook (#3782/#3793, 2026-07-06:
3151+
* two contributor PRs had zero trace anywhere for ~2 hours) within minutes, instead of waiting on
3152+
* `backfillRegisteredRepositories`'s 6-hour freshness window.
3153+
*
3154+
* FAIL-OPEN: a total read failure (repo not found, or the very first list page erroring) returns an all-zero,
3155+
* empty-`missingNumbers` result — it must never falsely report every local PR as "missing" just because GitHub
3156+
* was briefly unreachable. A LATER page failing mid-crawl keeps the pages already fetched (a partial remote
3157+
* list can only under-report divergence, never wrongly flag a real local PR as missing).
3158+
*/
3159+
export async function reconcileOpenPullRequests(env: Env, repoFullName: string): Promise<OpenPrReconciliationResult> {
3160+
const empty: OpenPrReconciliationResult = { repoFullName, remoteOpenCount: 0, localOpenCount: 0, missingNumbers: [] };
3161+
const repo = await getRepository(env, repoFullName);
3162+
if (!repo) return empty;
3163+
const token = await tokenForRepo(env, repo);
3164+
const admissionKey = repoAdmissionKeyForToken(env, repo, token);
3165+
const remoteNumbers: number[] = [];
3166+
for (let page = 1; page <= RECONCILE_OPEN_PRS_MAX_PAGES; page += 1) {
3167+
const result = await githubJsonWithHeaders<Array<{ number: number }>>(env, repoFullName, `/pulls?state=open&per_page=100&page=${page}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined);
3168+
if (!result) {
3169+
if (page === 1) return empty;
3170+
break;
3171+
}
3172+
remoteNumbers.push(...result.data.map((pr) => pr.number));
3173+
if (!hasNextPage(result.link)) break;
3174+
}
3175+
const localOpen = await listOpenPullRequests(env, repoFullName);
3176+
const localNumbers = new Set(localOpen.map((pr) => pr.number));
3177+
const missingNumbers = remoteNumbers.filter((number) => !localNumbers.has(number));
3178+
return { repoFullName, remoteOpenCount: remoteNumbers.length, localOpenCount: localOpen.length, missingNumbers };
3179+
}
3180+
31353181
// #2537: durable, webhook-invalidated cache for the bare PR-state read (GET /pulls/{n}). Unlike the request-local
31363182
// LiveGithubFacts memo (queue/processors.ts), this survives ACROSS webhook deliveries / sweep ticks, cutting
31373183
// repeat /pulls/{n} calls for an unchanged PR at the freshness-guard/readiness/dup-winner call sites. NEVER used

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { processDlqBatch } from "./queue/dlq";
55
import { processJob } from "./queue/processors";
66
import { isOrbBrokerEnabled } from "./orb/broker";
77
import { isOpsEnabled } from "./review/ops-wire";
8+
import { isPrReconciliationEnabled } from "./review/pr-reconciliation";
89
import { isRagEnabled } from "./review/rag-wire";
910
import { isSelfTuneEnabled } from "./review/selftune-wire";
1011
import {
@@ -105,6 +106,10 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController):
105106
const hour = scheduledAt.getUTCHours();
106107
const isHourly = minute === 0;
107108
const isFullSyncWindow = isHourly && hour % 6 === 0;
109+
// Self-heal (#audit-open-pr-reconciliation): every 10 minutes — much tighter than the hourly ops-alerts/
110+
// selftune cadence, since this exists specifically to catch a silently-lost webhook within minutes rather
111+
// than up to 6 hours (backfillRegisteredRepositories's freshness window).
112+
const isReconciliationWindow = minute % 10 === 0;
108113
// The light auto-maintain sweep runs EVERY cron tick (~every 2 min) so an approved+clean PR MERGES and a
109114
// red-CI non-owner PR CLOSES promptly — reviewbot parity (its cron fired every minute). It re-fetches LIVE CI +
110115
// mergeable and only ACTS (merge/close/hold); it never re-runs the AI, so it is cheap enough for this cadence.
@@ -170,6 +175,10 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController):
170175
// classified (MAINTENANCE_JOB_TYPES) so it defers under live-work pressure like every other periodic sweep here.
171176
if (selfHostedReviews) jobs.push({ type: "backlog-convergence-sweep", requestedBy: "schedule" });
172177
}
178+
// Self-heal (flag GITTENSORY_PR_RECONCILIATION). Every 10 minutes — see isReconciliationWindow above.
179+
// Enqueued ONLY when the flag is ON — flag-OFF (default) this job is never created, so the cron tick does
180+
// ZERO new work and the enqueued set is byte-identical to today.
181+
if (selfHostedReviews && isReconciliationWindow && isPrReconciliationEnabled(env)) jobs.push({ type: "reconcile-open-prs", requestedBy: "schedule" });
173182
if (isHourly) {
174183
jobs.push({ type: "refresh-registry", requestedBy: "schedule" });
175184
jobs.push({ type: "refresh-scoring-model", requestedBy: "schedule" });

src/queue/processors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ import {
491491
import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config";
492492
import { resolveUnlinkedIssueMatchDisposition } from "../review/unlinked-issue-guardrail";
493493
import { isOpsEnabled, runOpsAlerts } from "../review/ops-wire";
494+
import { isPrReconciliationEnabled, runOpenPrReconciliation } from "../review/pr-reconciliation";
494495
import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire";
495496
import {
496497
isCloseHoldOnly,
@@ -1111,6 +1112,12 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
11111112
// flag-OFF does zero work here too. Read-only telemetry — never throws into the queue.
11121113
if (isOpsEnabled(env)) await runOpsAlerts(env);
11131114
return;
1115+
case "reconcile-open-prs":
1116+
// Self-heal (flag GITTENSORY_PR_RECONCILIATION). Defense-in-depth: the cron only ENQUEUES this when the
1117+
// flag is ON, but a stale in-flight job that lands after a flag-flip must still no-op, so flag-OFF does
1118+
// zero work here too. Fails safe internally — never throws into the queue.
1119+
if (isPrReconciliationEnabled(env)) await runOpenPrReconciliation(env);
1120+
return;
11141121
case "selftune":
11151122
// Convergence (self-improve / auto-tune, flag GITTENSORY_REVIEW_SELFTUNE). Defense-in-depth: the cron only
11161123
// ENQUEUES this when the flag is ON, but a stale in-flight job that lands after a flag-flip must still

src/review/pr-reconciliation.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Self-heal (flag-gated by GITTENSORY_PR_RECONCILIATION). A "PR opened" webhook that silently vanishes (no
2+
// error, no audit event, no trace anywhere — see reconcileOpenPullRequests's doc comment for the 2026-07-06
3+
// incident that motivated this) previously wasn't caught until backfillRegisteredRepositories's opportunistic
4+
// resync, which skips any repo whose sync is "fresh" (up to 6 hours). This module runs a much tighter, dedicated
5+
// reconciliation on its own short cron cadence: list-diff GitHub's open PR numbers against the local table for
6+
// every acting-autonomy repo, and for any number GitHub has that the local table doesn't, immediately catch it
7+
// up (fetch full details, upsert, and enqueue a normal regate — the SAME pipeline a real webhook would have fed).
8+
//
9+
// Default OFF (like every other convergence capability) — flag-OFF this module is never invoked and the cron
10+
// enqueues no reconciliation job, byte-identical to today.
11+
12+
import { githubRateLimitAdmissionKeyForToken } from "../github/client";
13+
import { createInstallationToken } from "../github/app";
14+
import { fetchLivePullRequest, reconcileOpenPullRequests } from "../github/backfill";
15+
import { getRepository, listRepositories, upsertPullRequestFromGitHub } from "../db/repositories";
16+
import { isAgentConfigured } from "../settings/autonomy";
17+
import { resolveRepositorySettings } from "../settings/repository-settings";
18+
import { incr } from "../selfhost/metrics";
19+
import type { JobMessage } from "../types";
20+
import { errorMessage } from "../utils/json";
21+
import { isConvergenceRepoAllowed, listConvergenceRepos } from "./cutover-gate";
22+
23+
/** True when fast open-PR reconciliation is enabled. Flag-OFF (default) → the caller never invokes it, so the
24+
* cron enqueues no reconciliation job and the queue processor no-ops on a stale in-flight one. */
25+
export function isPrReconciliationEnabled(env: { GITTENSORY_PR_RECONCILIATION?: string | undefined }): boolean {
26+
return /^(1|true|yes|on)$/i.test(env.GITTENSORY_PR_RECONCILIATION ?? "");
27+
}
28+
29+
/** The same acting-autonomy repo set fanOutAgentRegateSweepJobs sweeps (mirrors sweep-watchdog.ts's own copy of
30+
* this selection) — this reconciliation only makes sense for repos gittensory is actually reviewing. */
31+
async function watchedRepos(env: Env): Promise<Array<{ fullName: string; installationId?: number }>> {
32+
const repositoriesByKey = new Map((await listRepositories(env)).map((repo) => [repo.fullName.toLowerCase(), repo]));
33+
const byKey = new Map<string, { fullName: string; installationId?: number }>();
34+
for (const repo of repositoriesByKey.values())
35+
byKey.set(repo.fullName.toLowerCase(), { fullName: repo.fullName, ...(typeof repo.installationId === "number" ? { installationId: repo.installationId } : {}) });
36+
for (const fullName of listConvergenceRepos(env)) {
37+
const repo = repositoriesByKey.get(fullName.toLowerCase());
38+
byKey.set(fullName.toLowerCase(), {
39+
fullName,
40+
...(typeof repo?.installationId === "number" ? { installationId: repo.installationId } : {}),
41+
});
42+
}
43+
const configured: Array<{ fullName: string; installationId?: number }> = [];
44+
for (const repo of byKey.values()) {
45+
try {
46+
const settings = await resolveRepositorySettings(env, repo.fullName);
47+
if (isConvergenceRepoAllowed(env, repo.fullName) || isAgentConfigured(settings.autonomy)) configured.push(repo);
48+
} catch {
49+
/* a settings blip on one repo must not abort the whole reconciliation scan */
50+
}
51+
}
52+
return configured;
53+
}
54+
55+
/** Catch up ONE missing PR number: fetch its full live payload, upsert it into the local table, and enqueue a
56+
* normal regate — the exact pipeline a real "PR opened" webhook would have fed it into, so it gets reviewed,
57+
* labeled, and gated exactly like any other PR. Best-effort: a fetch/upsert failure is logged and skipped (the
58+
* next reconciliation tick retries it; it is not lost by this catch-up failing). */
59+
async function catchUpMissingPullRequest(env: Env, repoFullName: string, installationId: number, prNumber: number): Promise<void> {
60+
try {
61+
const token = (await createInstallationToken(env, installationId).catch(() => undefined)) ?? env.GITHUB_PUBLIC_TOKEN;
62+
const admissionKey = githubRateLimitAdmissionKeyForToken(env, token, installationId);
63+
const live = await fetchLivePullRequest(env, repoFullName, prNumber, token, admissionKey);
64+
if (!live) {
65+
console.error(JSON.stringify({ level: "error", event: "open_pr_reconciliation_catch_up_fetch_failed", repository: repoFullName, prNumber }));
66+
return;
67+
}
68+
await upsertPullRequestFromGitHub(env, repoFullName, live);
69+
const message: JobMessage = { type: "agent-regate-pr", deliveryId: `reconcile:${repoFullName}#${prNumber}`, repoFullName, prNumber, installationId };
70+
await env.JOBS.send(message);
71+
} catch (error) {
72+
console.error(JSON.stringify({ level: "error", event: "open_pr_reconciliation_catch_up_failed", repository: repoFullName, prNumber, message: errorMessage(error).slice(0, 200) }));
73+
}
74+
}
75+
76+
export interface OpenPrDivergence {
77+
repoFullName: string;
78+
remoteOpenCount: number;
79+
localOpenCount: number;
80+
missingNumbers: number[];
81+
}
82+
83+
/**
84+
* The reconciliation scan, run on the cron tick. FAILS SAFE: a per-repo error is logged and the scan continues;
85+
* a top-level error is swallowed (this is best-effort self-heal, never a reason to fail the queue). Only an
86+
* INSTALLED repo (installationId present) is reconciled — a registered-but-uninstalled repo never gets a per-PR
87+
* fan-out regardless (#sweep-uninstalled-budget-waste), so reconciling it would just spend the shared
88+
* GITHUB_PUBLIC_TOKEN budget with no actionable outcome. Returns the divergences found (for tests / a caller
89+
* that wants to inspect them further).
90+
*
91+
* Caller MUST gate this on {@link isPrReconciliationEnabled} — it is invoked only from the flag-ON cron path, so
92+
* flag-OFF this function is never reached and the cron does zero new work.
93+
*/
94+
export async function runOpenPrReconciliation(env: Env): Promise<OpenPrDivergence[]> {
95+
const found: OpenPrDivergence[] = [];
96+
try {
97+
const repos = await watchedRepos(env);
98+
for (const repo of repos) {
99+
try {
100+
if (typeof repo.installationId !== "number") continue;
101+
const result = await reconcileOpenPullRequests(env, repo.fullName);
102+
if (result.missingNumbers.length === 0) continue;
103+
found.push({ repoFullName: repo.fullName, remoteOpenCount: result.remoteOpenCount, localOpenCount: result.localOpenCount, missingNumbers: result.missingNumbers });
104+
incr("gittensory_open_pr_reconciliation_missing_total", { repo: repo.fullName }, result.missingNumbers.length);
105+
console.error(
106+
JSON.stringify({
107+
level: "error",
108+
event: "open_pr_reconciliation_divergence",
109+
repository: repo.fullName,
110+
remoteOpenCount: result.remoteOpenCount,
111+
localOpenCount: result.localOpenCount,
112+
missingNumbers: result.missingNumbers,
113+
}),
114+
);
115+
for (const prNumber of result.missingNumbers) await catchUpMissingPullRequest(env, repo.fullName, repo.installationId, prNumber);
116+
} catch (error) {
117+
console.error(JSON.stringify({ level: "error", event: "open_pr_reconciliation_repo_error", repository: repo.fullName, message: errorMessage(error).slice(0, 200) }));
118+
}
119+
}
120+
} catch (error) {
121+
console.error(JSON.stringify({ level: "error", event: "open_pr_reconciliation_error", message: errorMessage(error).slice(0, 200) }));
122+
}
123+
return found;
124+
}

src/selfhost/maintenance-admission.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const MAINTENANCE_JOB_TYPES: ReadonlySet<string> = new Set([
5757
"notify-evaluate",
5858
"notify-deliver",
5959
"ops-alerts",
60+
"reconcile-open-prs",
6061
"selftune",
6162
"rag-index-repo",
6263
"backlog-convergence-sweep",

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,15 @@ export type JobMessage =
187187
type: "ops-alerts";
188188
requestedBy: "schedule" | "api" | "test";
189189
}
190+
| {
191+
// Self-heal (flag-gated by GITTENSORY_PR_RECONCILIATION). List-diff GitHub's open PR numbers against the
192+
// local table for every acting-autonomy repo — a much tighter cadence than backfillRegisteredRepositories's
193+
// 6-hour freshness window — and catch up (fetch + upsert + regate) any PR number GitHub has that the local
194+
// table doesn't (a silently-lost "opened" webhook). Enqueued on a short interval ONLY when the flag is ON
195+
// (index.ts), so flag-OFF this job never exists.
196+
type: "reconcile-open-prs";
197+
requestedBy: "schedule" | "api" | "test";
198+
}
190199
| {
191200
// Convergence (self-improve / auto-tune, flag-gated by GITTENSORY_REVIEW_SELFTUNE). Run the ported
192201
// self-improvement loop over gittensory's review-outcome data — compute tuning recommendations,

0 commit comments

Comments
 (0)