Skip to content

Commit 64f7dee

Browse files
committed
fix(gate): never plan an add and a remove of the manual-review label together
#10155 is flapping in production on beta.7: the label is removed and re-added roughly every 90 seconds -- four cycles in eight minutes, each pair a GitHub write and a subscriber notification. The audit trail shows both operations in the SAME pass, two seconds apart. Three post-plan transforms surface the manual-review hold -- the merge circuit-breaker, the close circuit-breaker, and the close-audit holdout (#8831). All three carried the identical idempotency check, which looks for an existing ADD (`labelOp !== "remove"`) and therefore cannot see a planned REMOVE. When the planner has already scheduled a release -- section 1b does, whenever nothing IT knows about still wants a hold -- the transform appended an add next to that remove, and the executor performed both. Latent until #10116. `noManualReviewHoldWanted` used to include `!mergeableStateUnstable`, which suppressed the release on exactly these PRs and accidentally masked the missing case. Removing that term (correctly -- it was the latch keeping #10098 stuck) let the release fire and exposed the contradiction. #10116 did not create this; the label was previously a sticky latch, quieter but strictly more broken. The planner cannot fix it from its side. Section 1b's doc calls noManualReviewHoldWanted "every reason that would ADD this label, in one place"; it is not, and cannot be, because these three run AFTER planning -- #8831 landed long after that comment was written. Extending the planner's list would just be one more thing to remember on the next transform. So one shared withManualReviewHoldLabel() drops a planned release of the same label before adding, and all three call sites use it. The contradiction becomes unrepresentable where the add happens. The remove is dropped rather than the add skipped: a transform only gets there by having just diverted a merge or a close, so its hold is strictly newer than the release decided before that diversion. Scoped to this label only -- dropping every label remove would silently defeat the stale-disposition-label cleanup, which is its own mutation test. gate.closeAuditHoldoutPct is 20 (the maximum) in the global Orb config, so about one in five would-close PRs is eligible for the path that triggered this. Closes #10164
1 parent 55cbeb9 commit 64f7dee

3 files changed

Lines changed: 171 additions & 45 deletions

File tree

src/review/close-audit-holdout.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import { DECISION_AUDIT_RUBRIC_VERSION } from "./decision-audit";
3232
import { recordAuditEvent } from "../db/repositories";
3333
import { incr } from "../selfhost/metrics";
34-
import { resolveAgentDispositionLabels, type AgentDispositionLabelSettings, type PlannedAgentAction } from "../settings/agent-actions";
34+
import { withManualReviewHoldLabel, type AgentDispositionLabelSettings, type PlannedAgentAction } from "../settings/agent-actions";
3535
import type { DecisionReplayHoldout } from "./decision-replay";
3636
import { hmacHex } from "../utils/crypto";
3737
import { errorMessage, nowIso } from "../utils/json";
@@ -106,21 +106,16 @@ export function holdoutEligibleClose(planned: PlannedAgentAction[]): PlannedAgen
106106
* downgradeCloseToHold's conversion exactly (drop + idempotent label add, never a merge/approve). */
107107
export function applyCloseAuditHoldout(planned: PlannedAgentAction[], labelSettings: AgentDispositionLabelSettings = {}): PlannedAgentAction[] {
108108
const isEligible = (action: PlannedAgentAction): boolean => action.actionClass === "close" && action.closeKind === "heuristic" && action.requiresApproval !== true;
109-
const labels = resolveAgentDispositionLabels(labelSettings);
110109
const next = planned.filter((action) => !isEligible(action));
111-
const alreadyNeedsReview = labels.manualReview !== null && next.some((action) => action.actionClass === "label" && action.label === labels.manualReview && action.labelOp !== "remove");
112-
if (labels.manualReview !== null && !alreadyNeedsReview) {
113-
next.push({
114-
actionClass: "label",
115-
// Authorized by `close` — the class actually being diverted (#label-scoping, mirrors downgradeCloseToHold).
116-
autonomyClass: "close",
117-
requiresApproval: false,
118-
reason: "close-audit holdout drew this PR — would-close held for human adjudication (#8831)",
119-
label: labels.manualReview,
120-
labelOp: "add",
121-
});
122-
}
123-
return next;
110+
// #10164: via withManualReviewHoldLabel, which also drops a planned RELEASE of this same label. This
111+
// transform is where the flap was actually observed -- JSONbored/loopover#10155 cycled the label roughly
112+
// every 90 seconds because the planner's release and this add both landed in one plan.
113+
return withManualReviewHoldLabel(next, labelSettings, {
114+
// Authorized by `close` — the class actually being diverted (#label-scoping, mirrors downgradeCloseToHold).
115+
autonomyClass: "close",
116+
requiresApproval: false,
117+
reason: "close-audit holdout drew this PR — would-close held for human adjudication (#8831)",
118+
});
124119
}
125120

126121
/**

src/settings/agent-actions.ts

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -585,29 +585,64 @@ function guardrailHoldReason(changedPaths: string[], hardGuardrailGlobs: string[
585585
* PURE + idempotent: with `holdOnly` false this returns the plan UNCHANGED (byte-identical, the common path);
586586
* with it true and no merge planned it is also a no-op. Only ever makes the system MORE cautious.
587587
*/
588+
/**
589+
* Add the manual-review hold label to a plan, dropping any planned REMOVE of that same label (#10164).
590+
*
591+
* Three post-plan transforms surface this hold -- the merge circuit-breaker, the close circuit-breaker, and
592+
* the close-audit holdout (#8831, in review/close-audit-holdout.ts). All three had the same idempotency
593+
* check, and all three had the same hole in it: it looks for an existing ADD (`labelOp !== "remove"`) and
594+
* therefore does not notice a planned REMOVE. When the planner has already decided to release the label --
595+
* which section 1b does whenever nothing it knows about still wants a hold -- the transform appended an add
596+
* NEXT TO that remove. One plan, both operations, same label.
597+
*
598+
* The executor performs both, so the label is removed and re-added every pass, forever. Observed on
599+
* JSONbored/loopover#10155 flapping roughly every 90 seconds: four add/remove cycles in eight minutes,
600+
* burning GitHub write quota and notifying subscribers each time.
601+
*
602+
* Section 1b cannot fix this from its side. Its doc calls `noManualReviewHoldWanted` "every reason that would
603+
* ADD this label, in one place", but these three run AFTER the planner, so their reasons are not knowable
604+
* there -- #8831 in particular was added long after that condition was written. Resolving the contradiction
605+
* where the add happens is what makes it structural instead of a list someone must remember to extend.
606+
*
607+
* The remove is dropped rather than the add skipped: a transform only reaches here because it has just
608+
* diverted a merge or a close, so the hold it is surfacing is strictly newer information than the release the
609+
* planner decided before that diversion.
610+
*/
611+
export function withManualReviewHoldLabel(
612+
planned: PlannedAgentAction[],
613+
labelSettings: AgentDispositionLabelSettings,
614+
action: Omit<PlannedAgentAction, "actionClass" | "label" | "labelOp">,
615+
): PlannedAgentAction[] {
616+
const labels = resolveAgentDispositionLabels(labelSettings);
617+
if (labels.manualReview === null) return planned;
618+
const isThisLabel = (candidate: PlannedAgentAction): boolean =>
619+
candidate.actionClass === "label" && candidate.label === labels.manualReview;
620+
// Drop a planned release of the very label we are about to add -- the contradiction this exists to prevent.
621+
const next = planned.filter((candidate) => !(isThisLabel(candidate) && candidate.labelOp === "remove"));
622+
if (next.some((candidate) => isThisLabel(candidate) && candidate.labelOp !== "remove")) return next; // already adding it
623+
next.push({ ...action, actionClass: "label", label: labels.manualReview, labelOp: "add" });
624+
return next;
625+
}
626+
588627
export function downgradeMergeToHold(planned: PlannedAgentAction[], holdOnly: boolean, labelSettings: AgentDispositionLabelSettings = {}): PlannedAgentAction[] {
589628
if (!holdOnly || !planned.some((action) => action.actionClass === "merge")) return planned;
590629
const labels = resolveAgentDispositionLabels(labelSettings);
591630
const next = planned.filter((action) => action.actionClass !== "merge");
592631
// The dropped merge implies the PR is review-good — re-label it for manual review (replacing a stale
593632
// ready-to-merge promise) so the held PR is clearly flagged for a person. Idempotent: only add when absent.
594-
const alreadyNeedsReview = labels.manualReview !== null && next.some((action) => action.actionClass === "label" && action.label === labels.manualReview && action.labelOp !== "remove");
595633
const stagedMerge = planned.find((action) => action.actionClass === "merge");
596-
if (labels.manualReview !== null && !alreadyNeedsReview) {
597-
next.push({
598-
actionClass: "label",
599-
// Authorized by `merge` (the class actually being downgraded here), NOT `review_state_label` — mirrors
600-
// the guardrail-hold label above (#label-scoping) so this hold label posts whenever merge autonomy is
601-
// acting, independent of whether the repo has separately opted into the advisory review_state_label class.
602-
autonomyClass: "merge",
603-
requiresApproval: stagedMerge?.requiresApproval ?? false,
604-
reason: "accuracy circuit-breaker engaged (merge precision dropped) — would-merge held for human review",
605-
label: labels.manualReview,
606-
labelOp: "add",
607-
});
608-
}
634+
// #10164: via withManualReviewHoldLabel so a planned RELEASE of this same label is dropped rather than
635+
// fought with, which is what made the label flap every pass.
636+
const withHold = withManualReviewHoldLabel(next, labelSettings, {
637+
// Authorized by `merge` (the class actually being downgraded here), NOT `review_state_label` — mirrors
638+
// the guardrail-hold label above (#label-scoping) so this hold label posts whenever merge autonomy is
639+
// acting, independent of whether the repo has separately opted into the advisory review_state_label class.
640+
autonomyClass: "merge",
641+
requiresApproval: stagedMerge?.requiresApproval ?? false,
642+
reason: "accuracy circuit-breaker engaged (merge precision dropped) — would-merge held for human review",
643+
});
609644
// Drop any ready-to-merge label add (the auto-merge it promised is now suppressed).
610-
return next.filter((action) => !(labels.readyToMerge !== null && action.actionClass === "label" && action.label === labels.readyToMerge && action.labelOp !== "remove"));
645+
return withHold.filter((action) => !(labels.readyToMerge !== null && action.actionClass === "label" && action.label === labels.readyToMerge && action.labelOp !== "remove"));
611646
}
612647

613648
/**
@@ -684,7 +719,6 @@ export function downgradeCloseToHold(
684719
isBreakerEligibleClose(action) &&
685720
(noConcreteEvidenceUnderProjectBreaker(action) || (action.closeConcreteEvidence === true && everyJustifyingCodeUntrustworthy(action)));
686721
if (!planned.some(isDowngradableClose)) return planned;
687-
const labels = resolveAgentDispositionLabels(labelSettings);
688722
// #9158 (label-close-split-brain, breaker-downgrade half): dropping a close here must ALSO drop any label
689723
// COUPLED to it -- the anti-abuse label pushed alongside a blacklist/contributor_cap/review_nag/copycat
690724
// close carries the SAME closeKind and is inseparable metadata on that close (see planContributorCapClose's/
@@ -704,22 +738,16 @@ export function downgradeCloseToHold(
704738
const next = planned.filter((action) => !isDowngradableClose(action) && !isOrphanedCoupledLabel(action));
705739
// The dropped close means the PR is held for a person — surface the manual-review label. Idempotent: only add when
706740
// absent (e.g. a guarded-but-passing plan may already carry it). NEVER adds a merge/approve.
707-
const alreadyNeedsReview = labels.manualReview !== null && next.some((action) => action.actionClass === "label" && action.label === labels.manualReview && action.labelOp !== "remove");
708741
const droppedClose = planned.find(isDowngradableClose);
709-
if (labels.manualReview !== null && !alreadyNeedsReview) {
710-
next.push({
711-
actionClass: "label",
712-
// Authorized by `close` (the class actually being downgraded here), NOT `review_state_label` — same
713-
// reasoning as downgradeMergeToHold's own manual-review label above (#label-scoping).
714-
autonomyClass: "close",
715-
requiresApproval: droppedClose?.requiresApproval ?? false,
716-
reason: "close-precision circuit-breaker engaged — would-close held for human review",
717-
label: labels.manualReview,
718-
labelOp: "add",
719-
});
720-
}
742+
// #10164: see withManualReviewHoldLabel — drops a planned release of this label instead of racing it.
721743
// KEEP the changes-requested label (it correctly states the PR is not mergeable) and every other action.
722-
return next;
744+
return withManualReviewHoldLabel(next, labelSettings, {
745+
// Authorized by `close` (the class actually being downgraded here), NOT `review_state_label` — same
746+
// reasoning as downgradeMergeToHold's own manual-review label above (#label-scoping).
747+
autonomyClass: "close",
748+
requiresApproval: droppedClose?.requiresApproval ?? false,
749+
reason: "close-precision circuit-breaker engaged — would-close held for human review",
750+
});
723751
}
724752

725753
function closeMessage(reasons: string[]): string {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
AGENT_LABEL_NEEDS_REVIEW,
5+
downgradeMergeToHold,
6+
withManualReviewHoldLabel,
7+
type PlannedAgentAction,
8+
} from "../../src/settings/agent-actions";
9+
import { applyCloseAuditHoldout } from "../../src/review/close-audit-holdout";
10+
11+
// #10164: a plan must never contain BOTH an add and a remove of the same label.
12+
//
13+
// Three post-plan transforms surface the manual-review hold -- the merge circuit-breaker, the close
14+
// circuit-breaker, and the close-audit holdout (#8831). All three checked idempotency by looking for an
15+
// existing ADD (`labelOp !== "remove"`), which by construction cannot see a planned REMOVE. So when the
16+
// planner had already decided to release the label (section 1b, whenever nothing it knows about still wants a
17+
// hold), the transform appended an add NEXT TO that remove and the executor performed both.
18+
//
19+
// Observed in production on JSONbored/loopover#10155: the label cycled roughly every 90 seconds -- four
20+
// add/remove pairs in eight minutes -- burning write quota and notifying subscribers on each flip. It was
21+
// latent until #10116 made section 1b's release actually fire for these PRs.
22+
//
23+
// The planner cannot prevent this from its side: these run AFTER planning, so their reasons are not knowable
24+
// to `noManualReviewHoldWanted` (whose comment nonetheless claims to list "every reason that would ADD this
25+
// label"). Resolving it where the add happens is what makes it structural.
26+
27+
const releasePlanned: PlannedAgentAction = {
28+
actionClass: "label",
29+
autonomyClass: "merge",
30+
requiresApproval: false,
31+
reason: 'manual-review hold resolved — clearing the "manual-review" label the bot applied',
32+
label: AGENT_LABEL_NEEDS_REVIEW,
33+
labelOp: "remove",
34+
};
35+
36+
const holdLabels = (plan: PlannedAgentAction[]) =>
37+
plan.filter((a) => a.actionClass === "label" && a.label === AGENT_LABEL_NEEDS_REVIEW);
38+
39+
describe("manual-review label flap (#10164)", () => {
40+
it("REGRESSION: the close-audit holdout drops a planned release instead of racing it", () => {
41+
// The exact production shape: planner released the label, holdout diverts the close and re-adds it.
42+
const planned: PlannedAgentAction[] = [
43+
{ actionClass: "close", requiresApproval: false, reason: "heuristic close", closeKind: "heuristic" },
44+
releasePlanned,
45+
];
46+
const out = applyCloseAuditHoldout(planned);
47+
const labelOps = holdLabels(out);
48+
expect(labelOps).toHaveLength(1);
49+
expect(labelOps[0]?.labelOp).toBe("add");
50+
expect(out.some((a) => a.actionClass === "close")).toBe(false);
51+
});
52+
53+
it("REGRESSION: the merge circuit-breaker does the same", () => {
54+
const planned: PlannedAgentAction[] = [
55+
{ actionClass: "merge", requiresApproval: false, reason: "green" },
56+
releasePlanned,
57+
];
58+
const labelOps = holdLabels(downgradeMergeToHold(planned, true));
59+
expect(labelOps).toHaveLength(1);
60+
expect(labelOps[0]?.labelOp).toBe("add");
61+
});
62+
63+
it("INVARIANT: no transform ever emits both operations for the same label", () => {
64+
// Stated over all three rather than per-transform, so a fourth added later is covered by the same rule
65+
// the moment it routes through withManualReviewHoldLabel.
66+
const withClose: PlannedAgentAction[] = [
67+
{ actionClass: "close", requiresApproval: false, reason: "heuristic close", closeKind: "heuristic" },
68+
releasePlanned,
69+
];
70+
const plans = [
71+
applyCloseAuditHoldout(withClose),
72+
downgradeMergeToHold([{ actionClass: "merge", requiresApproval: false, reason: "green" }, releasePlanned], true),
73+
withManualReviewHoldLabel([releasePlanned], {}, { autonomyClass: "close", requiresApproval: false, reason: "any hold" }),
74+
];
75+
for (const [i, plan] of plans.entries()) {
76+
const ops = new Set(holdLabels(plan).map((a) => a.labelOp));
77+
expect(ops.has("add") && ops.has("remove"), `plan ${i} contains both ops`).toBe(false);
78+
}
79+
});
80+
81+
it("stays idempotent: an add already in the plan is not duplicated", () => {
82+
const alreadyAdding: PlannedAgentAction = {
83+
actionClass: "label", autonomyClass: "close", requiresApproval: false,
84+
reason: "already held", label: AGENT_LABEL_NEEDS_REVIEW, labelOp: "add",
85+
};
86+
expect(holdLabels(withManualReviewHoldLabel([alreadyAdding], {}, { autonomyClass: "close", requiresApproval: false, reason: "second hold" }))).toHaveLength(1);
87+
});
88+
89+
it("leaves OTHER labels' removes alone — only this label's release is dropped", () => {
90+
// Over-broad filtering here would silently defeat the stale-disposition-label cleanup.
91+
const otherRemove: PlannedAgentAction = {
92+
actionClass: "label", autonomyClass: "review_state_label", requiresApproval: false,
93+
reason: "stale sibling", label: "ready-to-merge", labelOp: "remove",
94+
};
95+
const out = withManualReviewHoldLabel([otherRemove, releasePlanned], {}, { autonomyClass: "close", requiresApproval: false, reason: "hold" });
96+
expect(out).toContainEqual(otherRemove);
97+
});
98+
99+
it("is a no-op when the repo has no manual-review label configured", () => {
100+
const plan: PlannedAgentAction[] = [releasePlanned];
101+
expect(withManualReviewHoldLabel(plan, { manualReviewLabel: null }, { autonomyClass: "close", requiresApproval: false, reason: "hold" })).toBe(plan);
102+
});
103+
});

0 commit comments

Comments
 (0)