Skip to content

Commit a1367ff

Browse files
committed
fix(gate): stop labelling a PR for manual review in the same pass that merges it
The manual-review label announces a HOLD. Its emit condition checked `guardrailHit` alone, so once a clean escalated review released that hold (guardrailEscalationCleared), the planner emitted both in one pass: the merge AND the label telling a human to come look at it. Self-contradictory, and it leaves a manual-review label sitting on merged PRs in precisely the full-autonomy mode the feature exists to enable. Also closes the two patch-coverage gaps this PR still carried, both of them the "test both sides" class: - agent-actions: no test made guardrailHit, reviewGood and escalationConfigured true at once, so the fourth conjunct -- `onCleanReview === "proceed"`, the term the whole feature turns on -- was never evaluated at all. Now asserted in both directions against identical input, plus the case where "proceed" is set with NO escalation configured, which must NOT release (that promise is about an escalated review; honouring it without one would leave guarded paths weaker than before the feature). - focus-manifest: onCleanReview was covered through parse but not through resolveEffectiveSettings. Parsing it and dropping it during resolution would silently restore always-hold while the manifest still read as configured for full autonomy.
1 parent c606d65 commit a1367ff

3 files changed

Lines changed: 60 additions & 1 deletion

File tree

src/settings/agent-actions.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1248,7 +1248,13 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
12481248
// separate from review_state_label so a one-shot repo can opt into `manual-review` without also enabling the
12491249
// older ready/changes disposition labels. It is authorized by merge autonomy because it only fires when a
12501250
// would-merge PR is held for a human by a guardrail.
1251-
if (reviewGood && guardrailHit && labels.manualReview !== null && acting("merge") && !hasLabelOrPlanned(input.pr.labels, actions, labels.manualReview)) {
1251+
//
1252+
// `!guardrailEscalationCleared` is load-bearing (#9808/#9869): the label announces a HOLD, and a cleared
1253+
// escalation means there is no hold — the escalated review vouched for the guarded path and the merge below
1254+
// proceeds. Without this term the planner emitted both in the same pass, merging the PR while also tagging it
1255+
// for a human to look at, which is self-contradictory and leaves a manual-review label sitting on merged PRs
1256+
// in exactly the full-autonomy mode this feature exists to enable.
1257+
if (reviewGood && guardrailHit && !guardrailEscalationCleared && labels.manualReview !== null && acting("merge") && !hasLabelOrPlanned(input.pr.labels, actions, labels.manualReview)) {
12521258
actions.push({
12531259
actionClass: "label",
12541260
autonomyClass: "merge",

test/unit/agent-actions.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,43 @@ describe("planAgentMaintenanceActions (#778)", () => {
6666
expect(classes(collision)).not.toContain("merge");
6767
});
6868

69+
// #9808/#9869: a guardrail hit used to mean ONE thing -- suppress auto-merge and hold for a human -- while
70+
// buying no extra scrutiny at all. With an escalation configured, a CLEAN escalated review can now release
71+
// that hold instead. These two cases are the whole point of the feature and the only place all three
72+
// preconditions (guardrail hit, clean review, escalation configured) hold at once, which is what decides
73+
// whether onCleanReview is even consulted.
74+
it("guardrailEscalation.onCleanReview: proceed RELEASES the guardrail hold when the escalated review is clean (#9808)", () => {
75+
const escalated = {
76+
conclusion: "success" as const,
77+
autonomy: { merge: "auto" as const, review_state_label: "auto" as const },
78+
manualReviewLabel: "human-review",
79+
changedPaths: ["src/settings/agent-actions.ts"],
80+
hardGuardrailGlobs: ["src/settings/**"],
81+
guardrailEscalationEffort: "high",
82+
pr: { labels: [], mergeableState: "clean" as const, reviewDecision: "APPROVED" as const },
83+
};
84+
85+
// proceed: the escalated review vouched for the guarded path, so the PR merges and is NOT parked for a human.
86+
const proceed = planAgentMaintenanceActions(input({ ...escalated, guardrailEscalationOnCleanReview: "proceed" }));
87+
expect(proceed.some((a) => a.actionClass === "label" && a.label === "human-review")).toBe(false);
88+
expect(classes(proceed)).toContain("merge");
89+
90+
// hold (the default): identical input, opposite outcome -- the hold stands and the label goes on. Asserting
91+
// both against the SAME input is what proves onCleanReview is the deciding term rather than something else
92+
// in the escalated shape.
93+
const hold = planAgentMaintenanceActions(input({ ...escalated, guardrailEscalationOnCleanReview: "hold" }));
94+
expect(hold.some((a) => a.actionClass === "label" && a.label === "human-review")).toBe(true);
95+
expect(classes(hold)).not.toContain("merge");
96+
97+
// proceed with NO escalation configured must not release: "proceed" is a promise about an ESCALATED review,
98+
// so honouring it without one would hand guarded paths a weaker gate than they had before the feature.
99+
const unconfigured = planAgentMaintenanceActions(
100+
input({ ...escalated, guardrailEscalationEffort: null, guardrailEscalationOnCleanReview: "proceed" }),
101+
);
102+
expect(unconfigured.some((a) => a.actionClass === "label" && a.label === "human-review")).toBe(true);
103+
expect(classes(unconfigured)).not.toContain("merge");
104+
});
105+
69106
it("uses manualReviewLabel for manual holds without enabling ready/changes review-state labels", () => {
70107
const guarded = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, manualReviewLabel: "human-review", readyToMergeLabel: null, changesRequestedLabel: null, changedPaths: ["src/settings/agent-actions.ts"], hardGuardrailGlobs: ["src/settings/**"], pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } }));
71108
expect(guarded.some((a) => a.actionClass === "label" && a.label === "human-review" && a.autonomyClass === "merge")).toBe(true);

test/unit/focus-manifest.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,6 +1673,22 @@ describe("parseFocusManifest gate config", () => {
16731673
expect(parseFocusManifest({ gate }).gate.present).toBe(true);
16741674
}
16751675

1676+
// #9808/#9869: onCleanReview is the field that decides whether a CLEAN escalated review RELEASES the
1677+
// guardrail hold or merely records it, so it must survive the whole path -- parse, presence, round-trip,
1678+
// and (the gap this closes) the RESOLVE step that folds it into effective settings. Parsing it correctly
1679+
// and then dropping it during resolution would silently restore the old always-hold behaviour, with the
1680+
// manifest still reading as if full autonomy were configured.
1681+
const clean = parseFocusManifest({ gate: { guardrailEscalation: { effort: "high", onCleanReview: "proceed" } } });
1682+
expect(clean.gate.guardrailEscalationOnCleanReview).toBe("proceed");
1683+
expect(clean.gate.present).toBe(true);
1684+
expect(resolveEffectiveSettings({} as RepositorySettings, clean).guardrailEscalationOnCleanReview).toBe("proceed");
1685+
expect(parseFocusManifest({ gate: gateConfigToJson(clean.gate) }).gate).toEqual(clean.gate);
1686+
// onCleanReview ALONE flips presence, like each of its six siblings above.
1687+
expect(parseFocusManifest({ gate: { guardrailEscalation: { onCleanReview: "hold" } } }).gate.present).toBe(true);
1688+
// Left unset it stays null, so the resolver leaves effective settings untouched and the default (hold)
1689+
// stands -- the nullish arm of the same line, which is what a repo that never configured this gets.
1690+
expect(resolveEffectiveSettings({} as RepositorySettings, parseFocusManifest({ gate: { guardrailEscalation: { effort: "high" } } })).guardrailEscalationOnCleanReview).toBeUndefined();
1691+
16761692
// Partial escalation: unset fields stay null (each falls through to repo/global downstream).
16771693
const partial = parseFocusManifest({ gate: { guardrailEscalation: { effort: "high" } } });
16781694
expect(partial.gate.guardrailEscalationEffort).toBe("high");

0 commit comments

Comments
 (0)