Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ gate:
selfConsistencyRuns: 3
# provider: anthropic
# model: claude-opus-5
# What a CLEAN escalated review buys (#9808). `hold` (default): the PR still waits for a human even when
# the escalated review found nothing — human-in-the-loop mode. `proceed`: a clean escalated review
# (gate success + green CI) RELEASES the guardrail hold and the normal approve/merge path continues —
# full-autonomy mode, where the guarded path is protected by the escalated review instead of a queue.
# Fail-closed: `proceed` is inert unless at least one escalation knob above is actually set, so the hold
# can never be released without the extra scrutiny that justifies releasing it. Layered like every other
# manifest field (global -> per-repo), so repos can be flipped to full-auto one at a time.
# onCleanReview: proceed

advisoryCheckRuns:
- name: Contributor trust
Expand Down
9 changes: 9 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10197,6 +10197,15 @@
"nullable": true,
"minimum": 0,
"maximum": 1440
},
"guardrailEscalationOnCleanReview": {
"type": "string",
"nullable": true,
"enum": [
"hold",
"proceed",
null
]
}
},
"required": [
Expand Down
8 changes: 8 additions & 0 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,14 @@ gate:
selfConsistencyRuns: 3
# provider: anthropic
# model: claude-opus-5
# What a CLEAN escalated review buys (#9808). `hold` (default): the PR still waits for a human even when
# the escalated review found nothing — human-in-the-loop mode. `proceed`: a clean escalated review
# (gate success + green CI) RELEASES the guardrail hold and the normal approve/merge path continues —
# full-autonomy mode, where the guarded path is protected by the escalated review instead of a queue.
# Fail-closed: `proceed` is inert unless at least one escalation knob above is actually set, so the hold
# can never be released without the extra scrutiny that justifies releasing it. Layered like every other
# manifest field (global -> per-repo), so repos can be flipped to full-auto one at a time.
# onCleanReview: proceed

advisoryCheckRuns:
- name: Contributor trust
Expand Down
1 change: 1 addition & 0 deletions packages/loopover-contract/src/api-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ export const RepositorySettingsSchema = z
guardrailEscalationModel: z.string().nullable().optional(),
guardrailEscalationEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).nullable().optional(),
guardrailEscalationSelfConsistencyRuns: z.number().nullable().optional(),
guardrailEscalationOnCleanReview: z.enum(["hold", "proceed"]).nullable().optional(),
copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(),
copycatGateMinScore: z.number().nullable().optional(),
gateDryRun: z.boolean().optional(),
Expand Down
15 changes: 14 additions & 1 deletion packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ export type FocusManifestGateConfig = {
guardrailEscalationModel: string | null;
guardrailEscalationEffort: "low" | "medium" | "high" | "xhigh" | "max" | null;
guardrailEscalationSelfConsistencyRuns: number | null;
/** `gate.guardrailEscalation.onCleanReview` (#9808 second half): what a CLEAN escalated review buys.
* `hold` (default) keeps today's behavior -- the PR still waits for a human even when the escalated
* review found nothing. `proceed` releases the guardrail hold when the gate passed and CI is green, so a
* guarded path is protected by the ESCALATED REVIEW rather than by a human queue -- the full-autonomy
* mode. Fail-closed: `proceed` does nothing unless at least one escalation knob is actually set, so the
* hold can never be released without the extra scrutiny that justifies releasing it. */
guardrailEscalationOnCleanReview: "hold" | "proceed" | null;
aiReviewAllAuthors: boolean | null;
/** `gate.aiReview.closeConfidence` (#7): minimum calibrated AI-reviewer confidence (0-1) for an AI defect to BLOCK
* under `aiReview.mode: block`. null (unset) ⇒ the gate's 0.93 default. Clamped to [0,1] at parse time. */
Expand Down Expand Up @@ -659,6 +666,7 @@ export type FocusManifestSettings = Partial<
| "guardrailEscalationModel"
| "guardrailEscalationEffort"
| "guardrailEscalationSelfConsistencyRuns"
| "guardrailEscalationOnCleanReview"
| "aiReviewAllAuthors"
| "aiReviewConfirmedContributorsOnly"
| "closeOwnerAuthors"
Expand Down Expand Up @@ -1376,6 +1384,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
guardrailEscalationModel: null,
guardrailEscalationEffort: null,
guardrailEscalationSelfConsistencyRuns: null,
guardrailEscalationOnCleanReview: null,
aiReviewAllAuthors: null,
aiReviewCloseConfidence: null,
aiReviewSalvageabilityMinScore: null,
Expand Down Expand Up @@ -1928,6 +1937,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
guardrailEscalationModel: normalizeOptionalString(escalationRecord?.model, "gate.guardrailEscalation.model", warnings),
guardrailEscalationEffort: normalizeOptionalEnum(escalationRecord?.effort, "gate.guardrailEscalation.effort", ["low", "medium", "high", "xhigh", "max"] as const, warnings),
guardrailEscalationSelfConsistencyRuns: normalizeOptionalNonNegativeInt(escalationRecord?.selfConsistencyRuns, "gate.guardrailEscalation.selfConsistencyRuns", warnings),
guardrailEscalationOnCleanReview: normalizeOptionalEnum(escalationRecord?.onCleanReview, "gate.guardrailEscalation.onCleanReview", ["hold", "proceed"] as const, warnings),
aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings),
aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings),
aiReviewSalvageabilityMinScore: normalizeOptionalScore(aiReviewRecord?.salvageabilityMinScore, "gate.aiReview.salvageabilityMinScore", warnings),
Expand Down Expand Up @@ -2028,6 +2038,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.guardrailEscalationModel !== null ||
gate.guardrailEscalationEffort !== null ||
gate.guardrailEscalationSelfConsistencyRuns !== null ||
gate.guardrailEscalationOnCleanReview !== null ||
gate.ignoredCheckRuns !== null ||
gate.aiJudgmentBlockersMode !== null ||
gate.copycatMode !== null ||
Expand Down Expand Up @@ -2116,13 +2127,15 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
gate.guardrailEscalationProvider !== null ||
gate.guardrailEscalationModel !== null ||
gate.guardrailEscalationEffort !== null ||
gate.guardrailEscalationSelfConsistencyRuns !== null
gate.guardrailEscalationSelfConsistencyRuns !== null ||
gate.guardrailEscalationOnCleanReview !== null
) {
const escalation: Record<string, JsonValue> = {};
if (gate.guardrailEscalationProvider !== null) escalation.provider = gate.guardrailEscalationProvider;
if (gate.guardrailEscalationModel !== null) escalation.model = gate.guardrailEscalationModel;
if (gate.guardrailEscalationEffort !== null) escalation.effort = gate.guardrailEscalationEffort;
if (gate.guardrailEscalationSelfConsistencyRuns !== null) escalation.selfConsistencyRuns = gate.guardrailEscalationSelfConsistencyRuns;
if (gate.guardrailEscalationOnCleanReview !== null) escalation.onCleanReview = gate.guardrailEscalationOnCleanReview;
out.guardrailEscalation = escalation;
}
if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness;
Expand Down
4 changes: 4 additions & 0 deletions packages/loopover-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ export type RepositorySettings = {
guardrailEscalationModel?: string | null | undefined;
guardrailEscalationEffort?: "low" | "medium" | "high" | "xhigh" | "max" | null | undefined;
guardrailEscalationSelfConsistencyRuns?: number | null | undefined;
/** `gate.guardrailEscalation.onCleanReview` (#9808 second half): `proceed` releases the guardrail hold
* when the escalated review came back clean (gate success + CI green); `hold` (default) keeps a human in
* the loop even then. Fail-closed: `proceed` is inert unless an escalation knob is actually set. */
guardrailEscalationOnCleanReview?: "hold" | "proceed" | null | undefined;
/** Review EVERY PR's author, not only confirmed Gittensor contributors. Only meaningful when
* {@link aiReviewConfirmedContributorsOnly} is also `true` (that field opts INTO confirmed-only
* scoping in the first place — see its own doc comment for the full invariant: AI review runs for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,18 @@ test("a non-mapping guardrailEscalation is ignored wholesale, and absence leaves
const json = gateConfigToJson(absent.gate) as Record<string, unknown>;
assert.equal(json.guardrailEscalation, undefined);
});

test("onCleanReview parses, round-trips, makes the gate present alone, and rejects junk (#9808)", () => {
const parsed = parseFocusManifest({ gate: { guardrailEscalation: { onCleanReview: "proceed" } } });
assert.equal(parsed.gate.present, true);
assert.equal(parsed.gate.guardrailEscalationOnCleanReview, "proceed");
assert.deepEqual(parseFocusManifest({ gate: gateConfigToJson(parsed.gate) }).gate, parsed.gate);

const hold = parseFocusManifest({ gate: { guardrailEscalation: { onCleanReview: "hold", effort: "high" } } });
assert.equal(hold.gate.guardrailEscalationOnCleanReview, "hold");
assert.deepEqual(parseFocusManifest({ gate: gateConfigToJson(hold.gate) }).gate, hold.gate);

const junk = parseFocusManifest({ gate: { guardrailEscalation: { onCleanReview: "yolo" } } });
assert.equal(junk.gate.guardrailEscalationOnCleanReview, null);
assert.ok(junk.warnings.some((w) => /guardrailEscalation\.onCleanReview/.test(w)));
});
1 change: 1 addition & 0 deletions scripts/check-docs-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export const SETTINGS_ALIAS_MANIFEST: AliasManifestRow[] = [
{ field: "guardrailEscalationModel", aliases: ["guardrailEscalation:"] },
{ field: "guardrailEscalationEffort", aliases: ["guardrailEscalation:"] },
{ field: "guardrailEscalationSelfConsistencyRuns", aliases: ["guardrailEscalation:"] },
{ field: "guardrailEscalationOnCleanReview", aliases: ["guardrailEscalation:"] },
{ field: "aiReviewAllAuthors", aliases: ["allAuthors"] },
{ field: "aiReviewCloseConfidence", aliases: ["closeConfidence"] },
{ field: "aiReviewSalvageabilityMinScore", aliases: ["salvageabilityMinScore"] },
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ export const RepositorySettingsSchema = z
guardrailEscalationModel: z.string().nullable().optional(),
guardrailEscalationEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).nullable().optional(),
guardrailEscalationSelfConsistencyRuns: z.number().nullable().optional(),
guardrailEscalationOnCleanReview: z.enum(["hold", "proceed"]).nullable().optional(),
copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(),
copycatGateMinScore: z.number().nullable().optional(),
gateDryRun: z.boolean().optional(),
Expand Down
7 changes: 7 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2868,6 +2868,13 @@ function buildAgentMaintenancePlanInput(args: {
slopGateMinScore: settings.slopGateMinScore,
changedPaths,
hardGuardrailGlobs,
// #9808 second half: the escalation settings ride with the globs they modify, so the planner can release
// the guardrail hold when a clean escalated review has vouched for the guarded path.
guardrailEscalationOnCleanReview: settings.guardrailEscalationOnCleanReview ?? null,
guardrailEscalationEffort: settings.guardrailEscalationEffort ?? null,
guardrailEscalationSelfConsistencyRuns: settings.guardrailEscalationSelfConsistencyRuns ?? null,
guardrailEscalationModel: settings.guardrailEscalationModel ?? null,
guardrailEscalationProvider: settings.guardrailEscalationProvider ?? null,
manualReviewLabel: settings.manualReviewLabel,
readyToMergeLabel: settings.readyToMergeLabel,
changesRequestedLabel: settings.changesRequestedLabel,
Expand Down
36 changes: 34 additions & 2 deletions src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ export type AgentActionPlanInput = {
// review-good PRs; blockers, red CI, and base conflicts still close for close-eligible contributors.
changedPaths: string[];
hardGuardrailGlobs: string[];
/** #9808 second half: the guardrail-escalation settings, threaded as discrete fields like every other
* settings-derived input here. `onCleanReview: "proceed"` + at least one knob set + reviewGood releases
* the guardrail hold; anything less keeps today's behavior exactly. */
guardrailEscalationOnCleanReview?: "hold" | "proceed" | null | undefined;
guardrailEscalationEffort?: string | null | undefined;
guardrailEscalationSelfConsistencyRuns?: number | null | undefined;
guardrailEscalationModel?: string | null | undefined;
guardrailEscalationProvider?: string | null | undefined;
// Configured manual-review hold label. Undefined uses the default "manual-review"; null disables only the
// label, not the guardrail hold. Separate from review_state_label so operators can avoid ready/changes labels.
manualReviewLabel?: string | null | undefined;
Expand Down Expand Up @@ -1115,10 +1123,28 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// are not disposition. reviewGood is computed here (moved up from beside canMerge — same formula,
// gate passes AND CI green) because the disposition needs it.
const reviewGood = gatePassing && ciPassed;
// #9808 second half: a clean ESCALATED review releases the guardrail hold -- full-autonomy mode. All four
// conjuncts are load-bearing:
// - the mode is an explicit opt-in (`guardrailEscalation.onCleanReview: proceed`), default hold, layered
// global -> per-repo like every other manifest field, so an operator can flip one repo at a time;
// - at least one escalation knob must actually be SET -- the release is justified by extra scrutiny, so
// absent scrutiny there is nothing to vouch for the guarded path (fail-closed against a config that
// sets the mode but forgets the escalation);
// - reviewGood: the gate passed (which already folds in the AI verdict's blockers -- a consensus blocker
// fails the gate) AND CI is green;
// - guardrailHit, or there is nothing to clear.
const escalationConfigured =
input.guardrailEscalationEffort != null ||
input.guardrailEscalationSelfConsistencyRuns != null ||
input.guardrailEscalationModel != null ||
input.guardrailEscalationProvider != null;
const guardrailEscalationCleared =
guardrailHit && reviewGood && escalationConfigured && input.guardrailEscalationOnCleanReview === "proceed";
const disposition = derivePrDisposition({
mergeableState: input.pr.mergeableState,
reviewGood,
guardrailHit,
guardrailEscalationCleared,
migrationCollisionHold: input.migrationCollisionHold !== undefined,
unlinkedIssueMatchHold: input.unlinkedIssueMatchHold !== undefined,
priorityEligibilityHold: input.priorityEligibilityHold !== undefined,
Expand Down Expand Up @@ -1214,7 +1240,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// still separately wants manualReview) — without this, the cleanup would remove a label the fallback is
// about to re-add later in this same pass. See its own doc comment at the (former) point of use below.
const manualHoldReason =
guardrailHit
guardrailHit && !guardrailEscalationCleared
? `verdict=${conclusion}; ${guardrailReason}`
: ciUnverified
? "CI could not be verified"
Expand All @@ -1228,7 +1254,13 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// separate from review_state_label so a one-shot repo can opt into `manual-review` without also enabling the
// older ready/changes disposition labels. It is authorized by merge autonomy because it only fires when a
// would-merge PR is held for a human by a guardrail.
if (reviewGood && guardrailHit && labels.manualReview !== null && acting("merge") && !hasLabelOrPlanned(input.pr.labels, actions, labels.manualReview)) {
//
// `!guardrailEscalationCleared` is load-bearing (#9808/#9869): the label announces a HOLD, and a cleared
// escalation means there is no hold — the escalated review vouched for the guarded path and the merge below
// proceeds. Without this term the planner emitted both in the same pass, merging the PR while also tagging it
// for a human to look at, which is self-contradictory and leaves a manual-review label sitting on merged PRs
// in exactly the full-autonomy mode this feature exists to enable.
if (reviewGood && guardrailHit && !guardrailEscalationCleared && labels.manualReview !== null && acting("merge") && !hasLabelOrPlanned(input.pr.labels, actions, labels.manualReview)) {
actions.push({
actionClass: "label",
autonomyClass: "merge",
Expand Down
17 changes: 16 additions & 1 deletion src/settings/pr-disposition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ export type PrDispositionInput = Record<MergeHoldInput, boolean> & {
* held anyway (observed on JSONbored/loopover#9816, reason "mergeable_state is unstable — non-required
* check(s) not passing: Contributor trust"). Set ONLY when nothing else adverse was seen. */
unstableExplainedByIgnoredChecks?: boolean | undefined;
/** #9808 second half: the `guardrailHit` hold was CLEARED by a clean escalated review — see `releasedHolds`
* in derivePrDisposition. Resolved by the caller (agent-actions.ts), which requires ALL of:
* `guardrailEscalation.onCleanReview: proceed` configured, at least one escalation knob actually set (the
* extra scrutiny must exist before it can vouch for anything), and reviewGood (gate success — which folds in
* the AI verdict's blockers — plus green CI). Releases ONLY the guardrail term; every other hold in
* MERGE_HOLD_INPUTS is untouched. Default false ⇒ byte-identical to the pre-#9808 behaviour. */
guardrailEscalationCleared?: boolean | undefined;
};

export type PrDisposition = {
Expand Down Expand Up @@ -120,7 +127,15 @@ export function derivePrDisposition(input: PrDispositionInput): PrDisposition {
const unstableHolds = mergeable === "unstable" && input.unstableExplainedByIgnoredChecks !== true;
// Derived from MERGE_HOLD_INPUTS, so a hold declared in that table is folded in by construction and a
// new one can never be added-but-not-honoured.
const heldForManualReview = MERGE_HOLD_INPUT_KEYS.some((key) => input[key] === true) || unstableHolds;
//
// #9808: a hold may also be explicitly RELEASED. A guardrail hit whose escalated review came back clean no
// longer summons a human -- the guarded path is protected by the escalated review instead of by a queue.
// Expressed as a release map rather than by dropping the term, so the table stays the single declaration of
// what holds and this stays the single declaration of what can lift one.
const releasedHolds: Partial<Record<MergeHoldInput, boolean>> = {
guardrailHit: input.guardrailEscalationCleared === true,
};
const heldForManualReview = MERGE_HOLD_INPUT_KEYS.some((key) => input[key] === true && releasedHolds[key] !== true) || unstableHolds;
const heldForUnstableMergeState = unstableHolds;
const wouldApprove = input.reviewGood && !heldForManualReview && mergeable !== "conflict";
const wouldMerge = input.reviewGood && !heldForManualReview && mergeable === "clean";
Expand Down
1 change: 1 addition & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ function applyGateConfigOverrides(effective: RepositorySettings, gate: FocusMani
if (gate.guardrailEscalationModel !== null) effective.guardrailEscalationModel = gate.guardrailEscalationModel;
if (gate.guardrailEscalationEffort !== null) effective.guardrailEscalationEffort = gate.guardrailEscalationEffort;
if (gate.guardrailEscalationSelfConsistencyRuns !== null) effective.guardrailEscalationSelfConsistencyRuns = gate.guardrailEscalationSelfConsistencyRuns;
if (gate.guardrailEscalationOnCleanReview !== null) effective.guardrailEscalationOnCleanReview = gate.guardrailEscalationOnCleanReview;
if (gate.aiReviewAllAuthors !== null) effective.aiReviewAllAuthors = gate.aiReviewAllAuthors;
if (gate.aiReviewCloseConfidence !== null) effective.aiReviewCloseConfidence = gate.aiReviewCloseConfidence;
if (gate.aiReviewSalvageabilityMinScore !== null) effective.aiReviewSalvageabilityMinScore = gate.aiReviewSalvageabilityMinScore;
Expand Down
Loading
Loading