Skip to content
Merged
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
24 changes: 20 additions & 4 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ import { resolveRepoActionMode } from "../github/client";
import { ALL_TYPE_LABELS, resolvePrTypeLabel } from "../settings/pr-type-label";
import { fetchPublicContributorProfile } from "../github/public";
import { refreshRegistry } from "../registry/sync";
import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck, isTestPath } from "../rules/advisory";
import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck, isTestPath, reconcileGateEvaluationForGreenCi } from "../rules/advisory";
import { detectNotificationEvents } from "../notifications/events";
import { deliverNotification, detectIssueWatchEvents, evaluateNotificationEvent } from "../notifications/service";
import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model";
Expand Down Expand Up @@ -179,7 +179,7 @@ import { resolveRepositorySettings } from "../settings/repository-settings";
import type { LocalBranchAnalysisInput } from "../signals/local-branch";
import { runGittensoryAiReview } from "../services/ai-review";
import { secretLeakFinding } from "../review/safety";
import { buildReviewGroundingText, checkSummaryText as checkFailureSummaryText, isGroundingEnabled } from "../review/grounding-wire";
import { aiCiRefutationActive, buildReviewGroundingText, checkSummaryText as checkFailureSummaryText, isGroundingEnabled } from "../review/grounding-wire";
import { buildReviewRagContext, isRagEnabled } from "../review/rag-wire";
import { indexRepo, reindexChangedPaths } from "../review/rag-index";
import { isReputationEnabled, recordReputationOutcome, shouldSkipAiForReputation } from "../review/reputation-wire";
Expand Down Expand Up @@ -788,6 +788,15 @@ async function maybeRunAgentMaintenance(
const planned = planAgentMaintenanceActions({
conclusion: gate.conclusion,
blockerTitles: gate.blockers.map((blocker) => blocker.title),
// CI-refutation (#ai-ci-refutation): thread the blocker CODES + the active gate so the planner suppresses an
// AI-judgment-only failure (ai_consensus_defect / ai_review_split) on a green-CI PR — the deterministic
// validator overrules the model hallucination, so a clean+green PR MERGES instead of being false-closed.
// `aiCiRefutationEnabled` is the SAME grounding+convergence gate the public-comment reconciliation uses, passed
// as a single boolean so the refutation condition is unit-tested in the planner and this site carries no branch.
// Enabled=false (non-convergence / grounding-off) ⇒ the refutation is a no-op ⇒ byte-identical verdict. The
// codes are public-safe finding identifiers (no rubric/scoring/reward terms).
gateBlockerCodes: gate.blockers.map((blocker) => blocker.code),
aiCiRefutationEnabled: aiCiRefutationActive(env, repoFullName),
autonomy: settings.autonomy,
autoMaintain: settings.autoMaintain,
slopGateMinScore: settings.slopGateMinScore,
Expand Down Expand Up @@ -2704,7 +2713,6 @@ async function maybePublishPrPublicSurface(
// 3. The `ai_consensus_defect` surfaces exactly ONCE — as the Code-review blocker — never also in the
// gate signal row (which renders only the conclusion-derived status text, not the defect string).
if (unifiedCommentAllowed && gateEvaluation) {
const { rows, readinessTotal } = buildPublicPrPanelSignalRows({ repo, pr, profile, detection, queueHealth, collisions, preflight, settings, gate: gateEvaluation, duplicateWinnerEnabled });
// FIX B: the unified comment's file count + visual-capture path filter need the real diff — reuse the
// shared resolver (one resolve per review; inline-fetches when stored is still empty pre-detail-sync).
const unifiedFiles = await getReviewFiles();
Expand Down Expand Up @@ -2735,6 +2743,14 @@ async function maybePublishPrPublicSurface(
...(failingDetails.length > 0 ? { failingChecks: failingDetails.map((detail) => detail.name) } : {}),
...(failingDetails.length > 0 ? { failingDetails } : {}),
};
// CI-refutation for the PUBLIC comment (#ai-ci-refutation): when the gate FAILED solely on an AI-judgment
// blocker but the LIVE CI is GREEN, render the comment (headline + Gate panel row) as SUCCESS/advisory so it
// MATCHES the disposition (which merges such a PR) instead of a contradictory red "blocked/closed". Uses the
// SAME grounding+convergence gate as the disposition refutation (a single `aiCiRefutationActive` call so this
// site carries no branch), built AFTER the live CI is resolved and used for BOTH the panel rows and the
// comment body so the two never disagree. Gate OFF ⇒ commentGate === gateEvaluation (byte-identical comment).
const commentGate = reconcileGateEvaluationForGreenCi(gateEvaluation, ciState, aiCiRefutationActive(env, repoFullName));
const { rows, readinessTotal } = buildPublicPrPanelSignalRows({ repo, pr, profile, detection, queueHealth, collisions, preflight, settings, gate: commentGate, duplicateWinnerEnabled });
// Visual before/after capture (visual-capture port). Fires ONLY when (1) the global flag + per-repo
// cutover gate both allow it (screenshotsAllowed) AND (2) the PR touches WEB-VISIBLE files (isVisualPath
// — frontend pages / public OG images; backend .ts/.md/.json PRs never qualify). Fully wrapped in
Expand Down Expand Up @@ -2770,7 +2786,7 @@ async function maybePublishPrPublicSurface(
}
}
deterministicBody = buildUnifiedCommentBody({
gate: gateEvaluation,
gate: commentGate,
...(aiReview !== undefined ? { aiReview } : {}),
advisoryFindings: advisory.findings,
panelRows: rows,
Expand Down
11 changes: 11 additions & 0 deletions src/review/grounding-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { createInstallationToken } from "../github/app";
import type { CheckSummaryRecord, PullRequestFileRecord } from "../types";
import { repoParts } from "../utils/json";
import { isConvergenceRepoAllowed } from "./cutover-gate";
import {
buildGrounding,
type FileFetcher,
Expand All @@ -29,6 +30,16 @@ export function isGroundingEnabled(env: { GITTENSORY_REVIEW_GROUNDING?: string |
return /^(1|true|yes|on)$/i.test(env.GITTENSORY_REVIEW_GROUNDING ?? "");
}

/** True when the AI CI-refutation (#ai-ci-refutation) is ACTIVE for this repo: grounding is ON (the converged AI
* review feeds the finished CI status to the reviewer, so enforcing that ground truth is coherent) AND the repo
* is convergence-allowlisted. Centralized so the disposition refutation (agent-actions) and the public-comment
* reconciliation gate on the SAME condition — the merge/close action and the rendered comment can never disagree.
* A single call (not an inline `&&` at the call sites) so the processor carries no branch and this is the one
* place the condition is unit-tested. */
export function aiCiRefutationActive(env: Env, repoFullName: string): boolean {
return isGroundingEnabled(env) && isConvergenceRepoAllowed(env, repoFullName);
}

/** When ON, both grounding inputs (CI + full files) are gathered; OFF gathers neither. One switch keeps the
* flag-OFF path provably byte-identical (no partial grounding). */
function groundingFlags(env: { GITTENSORY_REVIEW_GROUNDING?: string | undefined }): GroundingFlags {
Expand Down
34 changes: 34 additions & 0 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,40 @@ export type GateCheckEvaluation = {
warnings: AdvisoryFinding[];
};

// AI-JUDGMENT blocker codes (#ai-ci-refutation): a gate failure driven SOLELY by these is the dual-model AI
// reviewer's OPINION, not a deterministic fact. Shared by the disposition refutation (agent-actions) and the
// public-comment reconciliation so the merge/close ACTION and the rendered comment always agree.
// `ai_review_inconclusive` is deliberately EXCLUDED — that is a "could not review" HOLD, not a false defect.
export const AI_JUDGMENT_BLOCKER_CODES = new Set<string>(["ai_consensus_defect", "ai_review_split"]);

/** True when the gate FAILED *solely* because of AI-judgment blockers (every blocker is an AI-judgment code).
* An empty blocker list is NOT an AI-judgment-only failure (there is nothing to refute). PURE. */
export function isAiJudgmentOnlyFailure(evaluation: GateCheckEvaluation): boolean {
return evaluation.conclusion === "failure" && evaluation.blockers.length > 0 && evaluation.blockers.every((blocker) => AI_JUDGMENT_BLOCKER_CODES.has(blocker.code));
}

/**
* Reconcile a gate evaluation with the deterministic CI for the PUBLIC review comment (#ai-ci-refutation).
* When the gate FAILED solely on an AI-judgment blocker (ai_consensus_defect / ai_review_split) but the real CI
* is GREEN, the AI claim is refuted by the validator — so the comment must render SUCCESS (advisory), matching
* the disposition (planAgentMaintenanceActions merges such a PR) instead of a contradictory red "blocked/closed"
* headline + Gate row. The AI concern stays VISIBLE without double-listing: the specific consensus defect still
* surfaces from the advisory findings as a raised concern under the green verdict, so we only clear the gate's
* hard blockers here. `enabled` is the caller's grounding+convergence gate (passed in so this stays a PURE,
* unit-testable function and the processor carries no branch); `enabled` false, `ciState !== "passed"`, or a
* non-AI-only failure ⇒ the evaluation is returned UNCHANGED.
*/
export function reconcileGateEvaluationForGreenCi(evaluation: GateCheckEvaluation, ciState: "passed" | "failed" | "unverified", enabled: boolean): GateCheckEvaluation {
if (!enabled || ciState !== "passed" || !isAiJudgmentOnlyFailure(evaluation)) return evaluation;
return {
...evaluation,
conclusion: "success",
title: "Gittensory Gate passed",
summary: "The AI review raised a concern, but the deterministic checks (CI) are green — the concern is advisory, not blocking.",
blockers: [],
};
}

export function buildRepositoryAdvisory(repo: RepositoryRecord | null, fullName: string): Advisory {
const findings: AdvisoryFinding[] = [];
if (!repo) {
Expand Down
40 changes: 34 additions & 6 deletions src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentActionClass, AutoMaintainPolicy, AutoMergeMethod, AutonomyPolicy } from "../types";
import type { GateCheckConclusion } from "../rules/advisory";
import { AI_JUDGMENT_BLOCKER_CODES, type GateCheckConclusion } from "../rules/advisory";
import { DEFAULT_AUTO_MAINTAIN_POLICY, autonomyRequiresApproval, isActingAutonomyLevel, resolveAutonomy } from "./autonomy";
import { changedPathsHittingGuardrail } from "../signals/change-guardrail";
import { AGENT_LABEL_PENDING_CLOSURE } from "../review/linked-issue-hard-rules";
Expand All @@ -13,6 +13,7 @@ const DEFAULT_SLOP_GATE_MIN_SCORE = 60;
// every action is independently gated by its own autonomy class, and the irreversible ones (merge / close)
// demand strong positive signals.


// The bucket labels the layer applies to reflect the gate verdict. Namespaced so a maintainer can filter on
// them and they never collide with project labels.
export const AGENT_LABEL_READY = "gittensory:ready-to-merge";
Expand Down Expand Up @@ -62,6 +63,14 @@ export type PlannedAgentAction = {
export type AgentActionPlanInput = {
conclusion: GateCheckConclusion;
blockerTitles: string[];
// The gate's blocking finding CODES (parallel to blockerTitles). Used by the CI-refutation rule
// (#ai-ci-refutation): when the gate FAILED solely because of AI-judgment blockers and CI is green, the
// AI claim is refuted by the deterministic validator. Optional/absent ⇒ the refutation is a no-op.
gateBlockerCodes?: string[] | undefined;
// Whether the AI CI-refutation is ACTIVE for this repo (the caller's grounding + convergence gate, passed as a
// single boolean so the refutation condition is fully unit-testable here and the processor carries no branch).
// Absent/false ⇒ the refutation never fires and the verdict is byte-identical to the raw gate.
aiCiRefutationEnabled?: boolean | undefined;
autonomy: AutonomyPolicy | null | undefined;
// Optional so the trigger can pass raw repo settings; both fall back to conservative defaults here.
autoMaintain?: AutoMaintainPolicy | undefined;
Expand Down Expand Up @@ -236,10 +245,29 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// Settle-before-decide: never approve / merge / close on a half-finished CI run.
if (input.ciState === "pending") return actions;

// CI-refutation of an AI-judgment-only failure (#ai-ci-refutation). When the gate FAILED *solely* because the
// dual-model AI reviewer flagged a defect (ai_consensus_defect / ai_review_split) but the deterministic CI is
// GREEN, the AI claim is refuted by the real validator → downgrade the verdict to SUCCESS for the disposition so
// a clean+green PR MERGES instead of being false-closed on a model hallucination. Requires EVERY blocker to be an
// AI-judgment code (a mixed failure with any deterministic blocker — duplicate / secret / slop / missing-issue /
// manifest — keeps `failure` and still closes) AND CI === passed (a red/unverified CI is the real signal and is
// never overridden). Empty/absent codes (the rule gated OFF at the boundary, or a non-AI failure) ⇒ no-op, so the
// verdict is byte-identical. The effective `conclusion` drives every gate-verdict decision below; the AI concern
// still surfaces in the review comment (an advisory finding), it just no longer auto-closes a green PR.
// Resolve the blocker codes ONCE (so the nullish fallback is exercised for both a present and an absent list,
// and the checks below carry no further `??` branch). Absent ⇒ [] ⇒ no AI-judgment-only failure.
const gateBlockerCodes = input.gateBlockerCodes ?? [];
const aiJudgmentOnlyFailure =
input.aiCiRefutationEnabled === true && input.conclusion === "failure" && gateBlockerCodes.length > 0 && gateBlockerCodes.every((code) => AI_JUDGMENT_BLOCKER_CODES.has(code));
// The refutation only fires on a GREEN CI (the deterministic validator that overrules the AI). A red/unverified
// CI is the real signal and is never overridden, so the verdict stays as the raw gate `failure`.
const refuteAiFailureOnGreenCi = aiJudgmentOnlyFailure && ciPassed;
const conclusion: GateCheckConclusion = refuteAiFailureOnGreenCi ? "success" : input.conclusion;

// Only SUCCESS earns the review-good auto-merge. A NEUTRAL gate flows (no longer silently returns []) but is
// NOT auto-merged — it falls through to a HELD + labeled state for review. (Auto-merging a neutral / grace
// PR is a separate trust/policy decision, deliberately NOT bundled into the harm-stop.) (#harm-stop)
const gatePassing = input.conclusion === "success";
const gatePassing = conclusion === "success";
// A changed path matching a hard guardrail forces manual review (suppresses auto-MERGE / auto-approve / auto-close).
// Fail SAFE on UNKNOWN paths (#1062): when guardrails are configured but the changed-file set is empty (cache
// not yet / no longer populated), we cannot prove the PR doesn't touch a guarded path, so treat it as a hit —
Expand Down Expand Up @@ -287,7 +315,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// have folded in optional / third-party checks and must keep the hard-guardrail manual hold.
// (Rebase-if-behind already ran above, so a red CI here is on the latest base — not a stale-base artifact.) (#ci-fail-closes-guarded)
const redVerifiedRequiredCi = ciFailed && input.ciRequiredContextsVerified === true;
const willClose = isContributor && acting("close") && (redVerifiedRequiredCi || (!guardrailHit && (ciFailed || input.conclusion === "failure" || isConflict)));
const willClose = isContributor && acting("close") && (redVerifiedRequiredCi || (!guardrailHit && (ciFailed || conclusion === "failure" || isConflict)));
// Linked-issue HARD-RULE close (#linked-issue-hard-rules). A DETERMINISTIC verdict about the LINKED ISSUE
// (owner-assigned / missing point-label / maintainer-only) — NOT an AI verdict, so there is no hallucination
// to guard against: this close fires REGARDLESS of `guardrailHit`. It still only ever closes a CONTRIBUTOR
Expand Down Expand Up @@ -336,10 +364,10 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
const reason = linkedIssueCloseInFlight
? `linked-issue hard rule: ${linkedIssueHardRule?.reason ?? "ineligible linked issue"}`
: !reviewGood
? `verdict=${input.conclusion}${ciReason ? `; ${ciReason}` : ""}`
? `verdict=${conclusion}${ciReason ? `; ${ciReason}` : ""}`
: heldForManualReview
? `verdict=${input.conclusion}; guarded path → manual review`
: `verdict=${input.conclusion}; CI green`;
? `verdict=${conclusion}; guarded path → manual review`
: `verdict=${conclusion}; CI green`;
if (!hasLabel(input.pr.labels, label)) {
actions.push({ actionClass: "label", requiresApproval: approval("label"), reason, label });
}
Expand Down
Loading
Loading