Skip to content

Commit efc7223

Browse files
feat(gate): guardrailEscalation.onCleanReview — release the guardrail hold when the escalated review is clean (#9869)
* feat(gate): guardrailEscalation.onCleanReview — release the guardrail hold when the escalated review is clean The second half of #9808, as an explicit MODE rather than a policy flip. review -- but the disposition still held it unconditionally: guardrailHit sat in heldForManualReview regardless of what the escalated review found. The 74 held PRs / 14 days kept landing on the maintainer, just with better reviews attached. gate.guardrailEscalation.onCleanReview: hold (default) today's behavior exactly -- human-in-the-loop proceed a clean escalated review releases the guardrail hold and the normal approve/merge path continues -- full-autonomy mode Because it is a manifest field it layers global -> per-repo like everything else, so repos can be flipped one at a time and the mode is reversible without a deploy. Fail-closed on every axis: - `proceed` is inert unless at least one escalation knob is actually SET: the release is justified by extra scrutiny, so absent scrutiny nothing vouches. - the release requires reviewGood -- gate success (which folds in the AI verdict's blockers) AND green CI -- and the disposition re-checks that independently of the caller's flag. - only the guardrail term is released: migration collisions, unlinked-issue holds, advisory-check holds, and unstable merge states all still hold. - the manual-hold reason no longer claims "guarded path -> manual review" for a cleared PR. * 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. --------- Co-authored-by: loopover-orb[bot] <296761690+loopover-orb[bot]@users.noreply.github.com>
1 parent f051cd2 commit efc7223

17 files changed

Lines changed: 223 additions & 6 deletions

File tree

.loopover.yml.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,14 @@ gate:
289289
selfConsistencyRuns: 3
290290
# provider: anthropic
291291
# model: claude-opus-5
292+
# What a CLEAN escalated review buys (#9808). `hold` (default): the PR still waits for a human even when
293+
# the escalated review found nothing — human-in-the-loop mode. `proceed`: a clean escalated review
294+
# (gate success + green CI) RELEASES the guardrail hold and the normal approve/merge path continues —
295+
# full-autonomy mode, where the guarded path is protected by the escalated review instead of a queue.
296+
# Fail-closed: `proceed` is inert unless at least one escalation knob above is actually set, so the hold
297+
# can never be released without the extra scrutiny that justifies releasing it. Layered like every other
298+
# manifest field (global -> per-repo), so repos can be flipped to full-auto one at a time.
299+
# onCleanReview: proceed
292300

293301
advisoryCheckRuns:
294302
- name: Contributor trust

apps/loopover-ui/public/openapi.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10201,6 +10201,15 @@
1020110201
"nullable": true,
1020210202
"minimum": 0,
1020310203
"maximum": 1440
10204+
},
10205+
"guardrailEscalationOnCleanReview": {
10206+
"type": "string",
10207+
"nullable": true,
10208+
"enum": [
10209+
"hold",
10210+
"proceed",
10211+
null
10212+
]
1020410213
}
1020510214
},
1020610215
"required": [

config/examples/loopover.full.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,14 @@ gate:
303303
selfConsistencyRuns: 3
304304
# provider: anthropic
305305
# model: claude-opus-5
306+
# What a CLEAN escalated review buys (#9808). `hold` (default): the PR still waits for a human even when
307+
# the escalated review found nothing — human-in-the-loop mode. `proceed`: a clean escalated review
308+
# (gate success + green CI) RELEASES the guardrail hold and the normal approve/merge path continues —
309+
# full-autonomy mode, where the guarded path is protected by the escalated review instead of a queue.
310+
# Fail-closed: `proceed` is inert unless at least one escalation knob above is actually set, so the hold
311+
# can never be released without the extra scrutiny that justifies releasing it. Layered like every other
312+
# manifest field (global -> per-repo), so repos can be flipped to full-auto one at a time.
313+
# onCleanReview: proceed
306314

307315
advisoryCheckRuns:
308316
- name: Contributor trust

packages/loopover-contract/src/api-schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ export const RepositorySettingsSchema = z
459459
guardrailEscalationModel: z.string().nullable().optional(),
460460
guardrailEscalationEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).nullable().optional(),
461461
guardrailEscalationSelfConsistencyRuns: z.number().nullable().optional(),
462+
guardrailEscalationOnCleanReview: z.enum(["hold", "proceed"]).nullable().optional(),
462463
copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(),
463464
copycatGateMinScore: z.number().nullable().optional(),
464465
gateDryRun: z.boolean().optional(),

packages/loopover-engine/src/focus-manifest.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ export type FocusManifestGateConfig = {
130130
guardrailEscalationModel: string | null;
131131
guardrailEscalationEffort: "low" | "medium" | "high" | "xhigh" | "max" | null;
132132
guardrailEscalationSelfConsistencyRuns: number | null;
133+
/** `gate.guardrailEscalation.onCleanReview` (#9808 second half): what a CLEAN escalated review buys.
134+
* `hold` (default) keeps today's behavior -- the PR still waits for a human even when the escalated
135+
* review found nothing. `proceed` releases the guardrail hold when the gate passed and CI is green, so a
136+
* guarded path is protected by the ESCALATED REVIEW rather than by a human queue -- the full-autonomy
137+
* mode. Fail-closed: `proceed` does nothing unless at least one escalation knob is actually set, so the
138+
* hold can never be released without the extra scrutiny that justifies releasing it. */
139+
guardrailEscalationOnCleanReview: "hold" | "proceed" | null;
133140
aiReviewAllAuthors: boolean | null;
134141
/** `gate.aiReview.closeConfidence` (#7): minimum calibrated AI-reviewer confidence (0-1) for an AI defect to BLOCK
135142
* under `aiReview.mode: block`. null (unset) ⇒ the gate's 0.93 default. Clamped to [0,1] at parse time. */
@@ -659,6 +666,7 @@ export type FocusManifestSettings = Partial<
659666
| "guardrailEscalationModel"
660667
| "guardrailEscalationEffort"
661668
| "guardrailEscalationSelfConsistencyRuns"
669+
| "guardrailEscalationOnCleanReview"
662670
| "aiReviewAllAuthors"
663671
| "aiReviewConfirmedContributorsOnly"
664672
| "closeOwnerAuthors"
@@ -1376,6 +1384,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
13761384
guardrailEscalationModel: null,
13771385
guardrailEscalationEffort: null,
13781386
guardrailEscalationSelfConsistencyRuns: null,
1387+
guardrailEscalationOnCleanReview: null,
13791388
aiReviewAllAuthors: null,
13801389
aiReviewCloseConfidence: null,
13811390
aiReviewSalvageabilityMinScore: null,
@@ -1928,6 +1937,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
19281937
guardrailEscalationModel: normalizeOptionalString(escalationRecord?.model, "gate.guardrailEscalation.model", warnings),
19291938
guardrailEscalationEffort: normalizeOptionalEnum(escalationRecord?.effort, "gate.guardrailEscalation.effort", ["low", "medium", "high", "xhigh", "max"] as const, warnings),
19301939
guardrailEscalationSelfConsistencyRuns: normalizeOptionalNonNegativeInt(escalationRecord?.selfConsistencyRuns, "gate.guardrailEscalation.selfConsistencyRuns", warnings),
1940+
guardrailEscalationOnCleanReview: normalizeOptionalEnum(escalationRecord?.onCleanReview, "gate.guardrailEscalation.onCleanReview", ["hold", "proceed"] as const, warnings),
19311941
aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings),
19321942
aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings),
19331943
aiReviewSalvageabilityMinScore: normalizeOptionalScore(aiReviewRecord?.salvageabilityMinScore, "gate.aiReview.salvageabilityMinScore", warnings),
@@ -2028,6 +2038,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
20282038
gate.guardrailEscalationModel !== null ||
20292039
gate.guardrailEscalationEffort !== null ||
20302040
gate.guardrailEscalationSelfConsistencyRuns !== null ||
2041+
gate.guardrailEscalationOnCleanReview !== null ||
20312042
gate.ignoredCheckRuns !== null ||
20322043
gate.aiJudgmentBlockersMode !== null ||
20332044
gate.copycatMode !== null ||
@@ -2116,13 +2127,15 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
21162127
gate.guardrailEscalationProvider !== null ||
21172128
gate.guardrailEscalationModel !== null ||
21182129
gate.guardrailEscalationEffort !== null ||
2119-
gate.guardrailEscalationSelfConsistencyRuns !== null
2130+
gate.guardrailEscalationSelfConsistencyRuns !== null ||
2131+
gate.guardrailEscalationOnCleanReview !== null
21202132
) {
21212133
const escalation: Record<string, JsonValue> = {};
21222134
if (gate.guardrailEscalationProvider !== null) escalation.provider = gate.guardrailEscalationProvider;
21232135
if (gate.guardrailEscalationModel !== null) escalation.model = gate.guardrailEscalationModel;
21242136
if (gate.guardrailEscalationEffort !== null) escalation.effort = gate.guardrailEscalationEffort;
21252137
if (gate.guardrailEscalationSelfConsistencyRuns !== null) escalation.selfConsistencyRuns = gate.guardrailEscalationSelfConsistencyRuns;
2138+
if (gate.guardrailEscalationOnCleanReview !== null) escalation.onCleanReview = gate.guardrailEscalationOnCleanReview;
21262139
out.guardrailEscalation = escalation;
21272140
}
21282141
if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness;

packages/loopover-engine/src/types/manifest-deps-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@ export type RepositorySettings = {
309309
guardrailEscalationModel?: string | null | undefined;
310310
guardrailEscalationEffort?: "low" | "medium" | "high" | "xhigh" | "max" | null | undefined;
311311
guardrailEscalationSelfConsistencyRuns?: number | null | undefined;
312+
/** `gate.guardrailEscalation.onCleanReview` (#9808 second half): `proceed` releases the guardrail hold
313+
* when the escalated review came back clean (gate success + CI green); `hold` (default) keeps a human in
314+
* the loop even then. Fail-closed: `proceed` is inert unless an escalation knob is actually set. */
315+
guardrailEscalationOnCleanReview?: "hold" | "proceed" | null | undefined;
312316
/** Review EVERY PR's author, not only confirmed Gittensor contributors. Only meaningful when
313317
* {@link aiReviewConfirmedContributorsOnly} is also `true` (that field opts INTO confirmed-only
314318
* scoping in the first place — see its own doc comment for the full invariant: AI review runs for

packages/loopover-engine/test/focus-manifest-review-knobs.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,18 @@ test("a non-mapping guardrailEscalation is ignored wholesale, and absence leaves
8888
const json = gateConfigToJson(absent.gate) as Record<string, unknown>;
8989
assert.equal(json.guardrailEscalation, undefined);
9090
});
91+
92+
test("onCleanReview parses, round-trips, makes the gate present alone, and rejects junk (#9808)", () => {
93+
const parsed = parseFocusManifest({ gate: { guardrailEscalation: { onCleanReview: "proceed" } } });
94+
assert.equal(parsed.gate.present, true);
95+
assert.equal(parsed.gate.guardrailEscalationOnCleanReview, "proceed");
96+
assert.deepEqual(parseFocusManifest({ gate: gateConfigToJson(parsed.gate) }).gate, parsed.gate);
97+
98+
const hold = parseFocusManifest({ gate: { guardrailEscalation: { onCleanReview: "hold", effort: "high" } } });
99+
assert.equal(hold.gate.guardrailEscalationOnCleanReview, "hold");
100+
assert.deepEqual(parseFocusManifest({ gate: gateConfigToJson(hold.gate) }).gate, hold.gate);
101+
102+
const junk = parseFocusManifest({ gate: { guardrailEscalation: { onCleanReview: "yolo" } } });
103+
assert.equal(junk.gate.guardrailEscalationOnCleanReview, null);
104+
assert.ok(junk.warnings.some((w) => /guardrailEscalation\.onCleanReview/.test(w)));
105+
});

scripts/check-docs-drift.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export const SETTINGS_ALIAS_MANIFEST: AliasManifestRow[] = [
182182
{ field: "guardrailEscalationModel", aliases: ["guardrailEscalation:"] },
183183
{ field: "guardrailEscalationEffort", aliases: ["guardrailEscalation:"] },
184184
{ field: "guardrailEscalationSelfConsistencyRuns", aliases: ["guardrailEscalation:"] },
185+
{ field: "guardrailEscalationOnCleanReview", aliases: ["guardrailEscalation:"] },
185186
{ field: "aiReviewAllAuthors", aliases: ["allAuthors"] },
186187
{ field: "aiReviewCloseConfidence", aliases: ["closeConfidence"] },
187188
{ field: "aiReviewSalvageabilityMinScore", aliases: ["salvageabilityMinScore"] },

src/openapi/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,7 @@ export const RepositorySettingsSchema = z
755755
guardrailEscalationModel: z.string().nullable().optional(),
756756
guardrailEscalationEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).nullable().optional(),
757757
guardrailEscalationSelfConsistencyRuns: z.number().nullable().optional(),
758+
guardrailEscalationOnCleanReview: z.enum(["hold", "proceed"]).nullable().optional(),
758759
copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(),
759760
copycatGateMinScore: z.number().nullable().optional(),
760761
gateDryRun: z.boolean().optional(),

src/queue/processors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2868,6 +2868,13 @@ function buildAgentMaintenancePlanInput(args: {
28682868
slopGateMinScore: settings.slopGateMinScore,
28692869
changedPaths,
28702870
hardGuardrailGlobs,
2871+
// #9808 second half: the escalation settings ride with the globs they modify, so the planner can release
2872+
// the guardrail hold when a clean escalated review has vouched for the guarded path.
2873+
guardrailEscalationOnCleanReview: settings.guardrailEscalationOnCleanReview ?? null,
2874+
guardrailEscalationEffort: settings.guardrailEscalationEffort ?? null,
2875+
guardrailEscalationSelfConsistencyRuns: settings.guardrailEscalationSelfConsistencyRuns ?? null,
2876+
guardrailEscalationModel: settings.guardrailEscalationModel ?? null,
2877+
guardrailEscalationProvider: settings.guardrailEscalationProvider ?? null,
28712878
manualReviewLabel: settings.manualReviewLabel,
28722879
readyToMergeLabel: settings.readyToMergeLabel,
28732880
changesRequestedLabel: settings.changesRequestedLabel,

0 commit comments

Comments
 (0)