Skip to content

Commit 2cbc087

Browse files
izaitsevfbIvan
andauthored
[torchci] AI advisor: stable-hash sanity cap + ci-no-td outage-guard bypass (#8223)
## Problem The Dr.CI AI-advisor auto-dispatch has an **outage guard** (`maxNewFailures`, `=8` for `pytorch/pytorch`) that bails a PR **entirely** — dispatches *nothing* — when its NEW-failure count exceeds the threshold. A flood of new failures is usually an outage, so the guard is sound for normal PRs. But it is **all-or-nothing**, and it misfires on PRs that are *expected* to fail broadly. A `ci-no-td` PR disables Target Determination and runs the full test suite, so a large failure count is normal there, not an outage. The guard then suppresses every verdict. Concrete case: pytorch/pytorch#188251 (a Triton-hash re-land, `ci-no-td`) got **4 verdicts** dispatched in early Dr.CI passes (while ≤8 failures), then ballooned to **42 distinct failing jobs**. Every subsequent pass tripped the guard and dispatched nothing — the verdict count froze at 4 instead of covering the real failures. ## Change - **`OUTAGE_GUARD_BYPASS_LABELS` (`["ci-no-td"]`)** — PRs with such a label skip the outage bail. The full-suite failure flood is expected, not an outage signal. - **`maxDispatchPerPr` sanity ceiling** (default `32`; `pytorch/pytorch=32`) — a hard cap on advisor analyses fanned out per PR head, so bypassing the outage guard can't fan out unbounded. When more failures are eligible than the remaining budget, the dispatched subset is chosen by a **stable hash salted with the head SHA**: the selection is consistent across cron passes, and the cap holds **cumulatively** (already-recorded signals consume the budget, so successive passes top up toward 32 rather than each re-picking a fresh 32). - **`advisor_pr_state` query** now also returns `labels.name`; `getPullRequestMeta` surfaces `labels`. The PR-state lookup is reordered to run after dedup (still paid only when there is fresh work to dispatch) so labels are available to the bypass decision. Non-`ci-no-td` behavior is unchanged: ≤ `maxNewFailures` dispatches all (the 32 cap never bites); a flood still bails as an outage. ## Test plan `yarn jest test/advisorDispatch.test.ts` — all green. New/updated coverage: - `stableHashSelect`: determinism for a given salt, bounds (`n<=0` → `[]`, `n>=len` → all), salt-sensitivity (different head SHA → different subset), subset-monotonicity (a smaller pick ⊆ a larger pick of the same set). - `ci-no-td` PR over the max does **not** bail and caps to `maxDispatchPerPr` (32) via stable hash. - Cumulative cap: 30 already-recorded + 10 fresh → only 2 new dispatched (32 − 30). - The outage-bail test updated for the new ordering (dedup + PR-state are read before the bail; still dispatches nothing for a non-bypass flood). Also: `tsc --noEmit` clean, `eslint` clean, `prettier --check` clean. 🤖 Drafted with iz2 on behalf of @izaitsevfb. --------- Co-authored-by: Ivan <izaitsevfb@meta.com>
1 parent ae21943 commit 2cbc087

4 files changed

Lines changed: 343 additions & 59 deletions

File tree

torchci/clickhouse_queries/advisor_pr_state/query.sql

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
-- Latest PR state (open/closed) + draft flag for a single PR, used to gate AI
2-
-- advisor auto-dispatch (skip closed/merged or draft PRs). Reads the
3-
-- default.pull_request mirror instead of the GitHub API. Filters on `number`
4-
-- (the table's primary/sorting key) for an indexed lookup; html_url pins the
5-
-- repo since PR numbers are not unique across repos.
1+
-- Latest PR state (open/closed) + draft flag + label names for a single PR, used
2+
-- to gate AI advisor auto-dispatch (skip closed/merged or draft PRs; bypass the
3+
-- outage guard on labels like ci-no-td). Reads the default.pull_request mirror
4+
-- instead of the GitHub API. Filters on `number` (the table's primary/sorting
5+
-- key) for an indexed lookup; html_url pins the repo since PR numbers are not
6+
-- unique across repos.
67
SELECT
78
state,
8-
draft
9+
draft,
10+
labels.name AS labels
911
FROM default.pull_request FINAL
1012
WHERE
1113
number = {prNumber: Int64}

torchci/lib/advisor/advisorConfig.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,37 @@
1212
// entirely (dispatches nothing) -- a flood of new failures is almost always an
1313
// outage rather than a PR problem, and we don't want to fan dozens of advisor
1414
// runs out per PR. Used as the fallback when a repo doesn't set its own.
15+
//
16+
// Exception: PRs carrying a label in OUTAGE_GUARD_BYPASS_LABELS are EXPECTED to
17+
// fail broadly (e.g. ci-no-td runs the full suite), so instead of bailing they
18+
// are capped to DEFAULT_MAX_DISPATCH_PER_PR via a stable-hash subset.
1519
export const DEFAULT_MAX_NEW_FAILURES = 8;
1620

21+
// Hard ceiling on how many advisor analyses auto-dispatch will ever fan out on a
22+
// single PR head. Applies on every path that proceeds past the outage guard; it
23+
// only bites when the failure count is large (i.e. an outage-guard-bypassing PR
24+
// or, in principle, a per-repo maxNewFailures raised above it). The dispatched
25+
// subset is chosen by a stable hash so it is consistent across cron passes.
26+
// Falls back here when a repo doesn't set its own.
27+
export const DEFAULT_MAX_DISPATCH_PER_PR = 32;
28+
29+
// PR labels that bypass the NEW-failure outage guard. `ci-no-td` disables Target
30+
// Determination, so the PR runs the full test suite -- a large failure count is
31+
// expected there and is NOT an outage signal. Such PRs are capped (see
32+
// DEFAULT_MAX_DISPATCH_PER_PR) rather than bailed.
33+
export const OUTAGE_GUARD_BYPASS_LABELS = ["ci-no-td"];
34+
1735
export interface AdvisorRepoConfig {
1836
// The workflow_dispatch file (on the repo's default branch) that runs the
1937
// advisor analysis for this repo.
2038
workflowFile: string;
2139
// Auto-dispatch bails entirely if a PR has more than this many NEW failures
22-
// (outage guard). Falls back to DEFAULT_MAX_NEW_FAILURES when unset.
40+
// (outage guard), unless the PR carries an OUTAGE_GUARD_BYPASS_LABELS label.
41+
// Falls back to DEFAULT_MAX_NEW_FAILURES when unset.
2342
maxNewFailures?: number;
43+
// Hard ceiling on advisor analyses dispatched per PR head (stable-hash subset
44+
// when exceeded). Falls back to DEFAULT_MAX_DISPATCH_PER_PR when unset.
45+
maxDispatchPerPr?: number;
2446
}
2547

2648
// Repos with the AI advisor enabled, keyed by "owner/repo".
@@ -33,6 +55,7 @@ export const ADVISOR_REPOS: Record<string, AdvisorRepoConfig> = {
3355
"pytorch/pytorch": {
3456
workflowFile: "claude-autorevert-advisor.yml",
3557
maxNewFailures: 8,
58+
maxDispatchPerPr: 32,
3659
},
3760
};
3861

@@ -54,3 +77,11 @@ export function getMaxNewFailures(owner: string, repo: string): number {
5477
DEFAULT_MAX_NEW_FAILURES
5578
);
5679
}
80+
81+
// The hard ceiling on advisor analyses dispatched per PR head, per repo.
82+
export function getMaxDispatchPerPr(owner: string, repo: string): number {
83+
return (
84+
getAdvisorRepoConfig(owner, repo)?.maxDispatchPerPr ??
85+
DEFAULT_MAX_DISPATCH_PER_PR
86+
);
87+
}

torchci/lib/advisor/advisorDispatch.ts

Lines changed: 126 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
// rest -- the misc.ai_advisor_dispatches dedup/retry state and the
66
// autoDispatchAdvisorForNewFailures loop -- backs the automatic Dr.CI path.
77

8+
import { createHash } from "crypto";
89
import dayjs from "dayjs";
910
import utc from "dayjs/plugin/utc";
1011
import {
1112
getAdvisorRepoConfig,
13+
getMaxDispatchPerPr,
1214
getMaxNewFailures,
15+
OUTAGE_GUARD_BYPASS_LABELS,
1316
} from "lib/advisor/advisorConfig";
1417
import {
1518
getClickhouseClientWritable,
@@ -42,6 +45,23 @@ export function signalKeyForJob(fullJobName: string): string {
4245
return `dr_ci_${fullJobName}`;
4346
}
4447

48+
/**
49+
* Deterministically pick up to `n` of `keys`, ordered by `sha1(key)`. The key is
50+
* the signal_key (job identity), with no PR/SHA salt, so the chosen subset is
51+
* stable across cron passes AND across PR updates -- the same jobs are picked
52+
* whenever they fail, rather than reshuffling on every push. Returns all keys
53+
* when `n >= keys.length`, and `[]` when `n <= 0`.
54+
*/
55+
export function stableHashSelect(keys: string[], n: number): string[] {
56+
if (n <= 0) return [];
57+
if (keys.length <= n) return keys;
58+
return [...keys]
59+
.map((k) => ({ k, h: createHash("sha1").update(k).digest("hex") }))
60+
.sort((a, b) => (a.h < b.h ? -1 : a.h > b.h ? 1 : 0))
61+
.slice(0, n)
62+
.map((x) => x.k);
63+
}
64+
4565
/**
4666
* Derive a LIKE pattern from a HUD job name that matches both PR variants
4767
* (-partial) and trunk variants (-all), and strips shard parentheticals.
@@ -388,12 +408,12 @@ export async function getPullRequestMeta(
388408
owner: string,
389409
repo: string,
390410
prNumber: number
391-
): Promise<{ state: string; draft: boolean }> {
411+
): Promise<{ state: string; draft: boolean; labels: string[] }> {
392412
const rows = await queryClickhouseSaved("advisor_pr_state", {
393413
prNumber,
394414
htmlUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
395415
});
396-
if (rows.length === 0) return { state: "open", draft: false };
416+
if (rows.length === 0) return { state: "open", draft: false, labels: [] };
397417
// Decode draft robustly: depending on the CH client/format a Bool can come
398418
// back as a JS boolean, a number, or a string ("0"/"1"/"true"/"false"). A
399419
// naive Boolean() would treat the string "0"/"false" as truthy and wrongly
@@ -404,7 +424,11 @@ export async function getPullRequestMeta(
404424
draftRaw === 1 ||
405425
draftRaw === "1" ||
406426
draftRaw === "true";
407-
return { state: rows[0].state as string, draft };
427+
// labels.name comes back as a string array; guard against a non-array shape.
428+
const labels = Array.isArray(rows[0].labels)
429+
? (rows[0].labels as string[])
430+
: [];
431+
return { state: rows[0].state as string, draft, labels };
408432
}
409433

410434
// Injectable dependencies so the orchestration logic is unit-testable without
@@ -454,8 +478,14 @@ export function autoDispatchEnabled(owner: string, repo: string): boolean {
454478
* rather than dispatch without a dedup record),
455479
* - 'dispatched' is written on success; a 'failed' row supersedes the pre row
456480
* when the dispatch throws, re-enabling retry up to MAX_DISPATCH_RETRIES,
457-
* - bails entirely (dispatches nothing) if a PR has more NEW failures than the
458-
* per-repo max (outage guard),
481+
* - outage guard: bails entirely (dispatches nothing) if a PR has more NEW
482+
* failures than the per-repo max -- UNLESS the PR carries an
483+
* OUTAGE_GUARD_BYPASS_LABELS label (e.g. ci-no-td runs the full suite, so a
484+
* large failure count is expected, not an outage),
485+
* - sanity cap: caps a pass to maxDispatchPerPr analyses for the current
486+
* failure snapshot (budget = cap - already-dispatched). When more fresh
487+
* failures are eligible than the budget, the subset is the lowest-sha1 of
488+
* their signal_keys -- stable across cron passes and across PR updates,
459489
* - skips PRs that are not open (closed/merged) or, by default, draft -- the
460490
* PR state is only looked up once there is fresh work to dispatch.
461491
*/
@@ -486,20 +516,9 @@ export async function autoDispatchAdvisorForNewFailures(
486516
const signalKeys = Array.from(byKey.keys());
487517
if (signalKeys.length === 0) return;
488518

489-
// Outage guard: if a PR has more NEW failures than the per-repo max, bail
490-
// entirely (dispatch nothing). A flood of new failures is almost always an
491-
// outage, not a PR problem, and we don't want to fan dozens of advisor runs
492-
// out across one PR.
493-
const maxNewFailures = getMaxNewFailures(owner, repo);
494-
if (byKey.size > maxNewFailures) {
495-
console.log(
496-
`advisor auto-dispatch: PR ${prNumber} has ${byKey.size} new failures ` +
497-
`(> ${maxNewFailures}); skipping auto-dispatch (likely an outage)`
498-
);
499-
return;
500-
}
501-
502-
// Fail closed: if we cannot read dedup state, dispatch nothing.
519+
// Fail closed: if we cannot read dedup state, dispatch nothing. Read before
520+
// the outage guard so the per-PR cap can account for already-recorded signals
521+
// and so a pass with no fresh work returns before paying for a PR lookup.
503522
let states: Map<string, DispatchStateRow>;
504523
try {
505524
states = await deps.readDispatchStates(owner, repo, headSha, signalKeys);
@@ -511,52 +530,114 @@ export async function autoDispatchAdvisorForNewFailures(
511530
return;
512531
}
513532

514-
// Which failures still need a dispatch (skip already dispatching/dispatched,
515-
// or failed-and-out-of-retries). The byKey set is already bounded by the
516-
// outage guard above.
517-
const toDispatch: {
533+
// Split the work. `fresh` (no prior record) is subject to the per-PR cap
534+
// below; `failedRetry` (a prior dispatch whose POST threw, still under the
535+
// retry limit) re-uses an already-counted slot, so it is always allowed. Skip
536+
// already dispatching/dispatched and exhausted-failed signals.
537+
const fresh: { signalKey: string; job: RecentWorkflowsData }[] = [];
538+
const failedRetry: {
518539
signalKey: string;
519540
job: RecentWorkflowsData;
520541
retryCount: number;
521542
}[] = [];
522543
for (const [signalKey, job] of byKey) {
523544
const prev = states.get(signalKey);
524-
if (prev) {
525-
// Already dispatching or completed -> permanent skip.
526-
if (prev.state === "dispatching" || prev.state === "dispatched") continue;
527-
// Failed and out of retries -> permanent skip.
528-
if (prev.state === "failed" && prev.retryCount >= MAX_DISPATCH_RETRIES) {
529-
continue;
530-
}
545+
if (!prev) {
546+
fresh.push({ signalKey, job });
547+
continue;
548+
}
549+
if (prev.state === "dispatching" || prev.state === "dispatched") continue;
550+
if (prev.state === "failed") {
551+
if (prev.retryCount >= MAX_DISPATCH_RETRIES) continue;
552+
failedRetry.push({ signalKey, job, retryCount: prev.retryCount + 1 });
531553
}
532-
const retryCount = prev?.state === "failed" ? prev.retryCount + 1 : 0;
533-
toDispatch.push({ signalKey, job, retryCount });
534554
}
535-
if (toDispatch.length === 0) return;
555+
// Nothing fresh and nothing to retry -> return before paying for a PR lookup.
556+
if (fresh.length === 0 && failedRetry.length === 0) return;
536557

537558
// Don't auto-dispatch on PRs that aren't open: a closed/merged PR won't be
538559
// worked on, and (by default) draft PRs are work-in-progress. Looked up only
539560
// now -- after dedup -- so the PR lookup is paid only when there is fresh
540-
// work. Fail closed: a lookup error skips this pass (the next pass retries).
561+
// work. The labels also drive the outage-guard bypass below. Fail closed: a
562+
// lookup error skips this pass (the next pass retries).
563+
let pr: { state: string; draft: boolean; labels: string[] };
541564
try {
542-
const pr = await deps.getPullRequestMeta(owner, repo, prNumber);
543-
if (pr.state !== "open") {
544-
console.log(
545-
`advisor auto-dispatch: PR ${prNumber} is ${pr.state}; skipping`
546-
);
547-
return;
548-
}
549-
if (pr.draft && SKIP_DRAFT_PRS) {
550-
console.log(`advisor auto-dispatch: PR ${prNumber} is a draft; skipping`);
551-
return;
552-
}
565+
pr = await deps.getPullRequestMeta(owner, repo, prNumber);
553566
} catch (e) {
554567
console.error(
555568
`advisor auto-dispatch: PR-state lookup failed for PR ${prNumber}, skipping pass`,
556569
e
557570
);
558571
return;
559572
}
573+
if (pr.state !== "open") {
574+
console.log(
575+
`advisor auto-dispatch: PR ${prNumber} is ${pr.state}; skipping`
576+
);
577+
return;
578+
}
579+
if (pr.draft && SKIP_DRAFT_PRS) {
580+
console.log(`advisor auto-dispatch: PR ${prNumber} is a draft; skipping`);
581+
return;
582+
}
583+
584+
// Outage guard: a flood of NEW failures is almost always an outage, not a PR
585+
// problem, so bail entirely -- UNLESS the PR carries a bypass label (e.g.
586+
// ci-no-td runs the full suite, where a large failure count is expected). In
587+
// the bypass case the sanity cap below bounds the fan-out instead.
588+
const bypassOutageGuard = pr.labels.some((l) =>
589+
OUTAGE_GUARD_BYPASS_LABELS.includes(l)
590+
);
591+
const maxNewFailures = getMaxNewFailures(owner, repo);
592+
if (!bypassOutageGuard && byKey.size > maxNewFailures) {
593+
console.log(
594+
`advisor auto-dispatch: PR ${prNumber} has ${byKey.size} new failures ` +
595+
`(> ${maxNewFailures}); skipping auto-dispatch (likely an outage)`
596+
);
597+
return;
598+
}
599+
600+
// Sanity cap: in one pass, don't fan out more than maxDispatchPerPr advisor
601+
// analyses for this PR's current failure snapshot. The budget is
602+
// maxDispatchPerPr minus the currently-failing signals already dispatched
603+
// (states.size), so as failures accumulate over cron passes the running total
604+
// for a fixed snapshot stays at the cap. When more fresh failures are eligible
605+
// than the budget, the subset is the lowest-`sha1(signalKey)` `budget` of them
606+
// -- a stable choice that does not churn across passes and stays the same
607+
// across PR updates (the same jobs are picked whenever they fail). This is a
608+
// per-snapshot cap, not a strict per-head-lifetime one: if the failing set
609+
// turns over on a fixed head (e.g. failures re-run green and different jobs
610+
// fail), the budget can re-open and the head's cumulative total can exceed the
611+
// cap -- an accepted simplification, since that path is rare.
612+
const maxDispatchPerPr = getMaxDispatchPerPr(owner, repo);
613+
const budget = Math.max(0, maxDispatchPerPr - states.size);
614+
const selected = new Set(
615+
stableHashSelect(
616+
fresh.map((f) => f.signalKey),
617+
budget
618+
)
619+
);
620+
if (fresh.length > selected.size) {
621+
console.log(
622+
`advisor auto-dispatch: PR ${prNumber} capping ${fresh.length} fresh ` +
623+
`failures to ${selected.size} (maxDispatchPerPr=${maxDispatchPerPr}, ` +
624+
`${states.size} already dispatched)`
625+
);
626+
}
627+
628+
// failedRetry first (already counted against the budget), then the capped
629+
// fresh subset.
630+
const toDispatch: {
631+
signalKey: string;
632+
job: RecentWorkflowsData;
633+
retryCount: number;
634+
}[] = [
635+
...failedRetry,
636+
...fresh
637+
.filter((f) => selected.has(f.signalKey))
638+
.map((f) => ({ ...f, retryCount: 0 })),
639+
];
640+
if (toDispatch.length === 0) return;
560641

561642
for (const { signalKey, job, retryCount } of toDispatch) {
562643
const base = {

0 commit comments

Comments
 (0)