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
30 changes: 24 additions & 6 deletions scripts/replay-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
//
// node --experimental-strip-types scripts/replay-decision.ts <bundle.json>
// ... | node --experimental-strip-types scripts/replay-decision.ts -
// node --experimental-strip-types scripts/replay-decision.ts <bundle.json> --at <epoch ms>
//
// #9028: `--at` names the instant to replay AT. Omit it to replay at the instant the decision itself recorded
// (`replayInput.clock.nowMs`) — the bit-exact case. Passing a DIFFERENT instant exits 1 with a `clock`
// divergence rather than reporting a match: time is a decision INPUT, and a clock-dependent rule
// (`gate.requireFreshRebaseWindow`) can flip purely because the wall clock moved.
//
// The bundle is one JSON object: { record: {...decision_records row}, replayInput: {...replay_json} }.
// EXTRACT (operator, against the instance DB):
Expand All @@ -24,8 +30,13 @@
import { readFileSync } from "node:fs";
import { replayDecision, type DecisionReplayInput, type ReplayableRecord } from "../src/review/decision-replay";

/** Parse + normalize a bundle (snake_case SQL rows accepted) and replay it. Exported for tests. */
export function runReplayBundle(raw: string): { outcome: ReturnType<typeof replayDecision> | null; error?: string } {
/** Parse + normalize a bundle (snake_case SQL rows accepted) and replay it. Exported for tests.
*
* #9028: `atMs` names the instant to replay AT. Omitted (the default) replays at the instant the decision
* recorded, which is the bit-exact case. Supplying a DIFFERENT instant is reported as a `clock` divergence,
* never silently accepted — a clock-dependent rule can legitimately flip its answer as the wall clock moves,
* so "it still matches at a different instant" is not a re-derivation of the original decision. */
export function runReplayBundle(raw: string, atMs?: number): { outcome: ReturnType<typeof replayDecision> | null; error?: string } {
let bundle: { record?: Record<string, unknown>; replayInput?: unknown };
try {
bundle = JSON.parse(raw) as never;
Expand All @@ -47,18 +58,25 @@ export function runReplayBundle(raw: string): { outcome: ReturnType<typeof repla
if (!record || !replayInput || !Array.isArray(replayInput.findings) || typeof replayInput.evaluated !== "object") {
return { outcome: null, error: "bundle must carry {record: {id, reason_code|reasonCode, action}, replayInput: {findings, policy, evaluated}}" };
}
return { outcome: replayDecision(record, replayInput) };
return { outcome: replayDecision(record, replayInput, atMs === undefined ? {} : { nowMs: atMs }) };
}

const invokedDirectly = process.argv[1]?.endsWith("replay-decision.ts") === true;
if (invokedDirectly) {
const source = process.argv[2];
const argv = process.argv.slice(2);
const atIndex = argv.indexOf("--at");
const atRaw = atIndex === -1 ? undefined : argv[atIndex + 1];
if (atIndex !== -1 && (atRaw === undefined || !Number.isFinite(Number(atRaw)))) {
console.error("replay-decision: --at requires a Unix-epoch-milliseconds value");
process.exit(2);
}
const source = argv.filter((arg, index) => index !== atIndex && index !== atIndex + 1)[0];
if (!source) {
console.error("usage: replay-decision.ts <bundle.json | ->");
console.error("usage: replay-decision.ts <bundle.json | -> [--at <epoch ms>]");
process.exit(2);
}
const raw = source === "-" ? readFileSync(0, "utf8") : readFileSync(source, "utf8");
const { outcome, error } = runReplayBundle(raw);
const { outcome, error } = runReplayBundle(raw, atRaw === undefined ? undefined : Number(atRaw));
if (!outcome) {
console.error(`replay-decision: ${error}`);
process.exit(2);
Expand Down
10 changes: 10 additions & 0 deletions src/queue/ai-review-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { buildPullRequestAdvisory } from "../rules/advisory";
import { recordAuditEvent, getDecryptedRepositoryAiKey, getRepository, listCheckSummaries, listPullRequestFiles } from "../db/repositories";
import { registerHeldLock, unregisterHeldLock } from "./held-lock-registry";
import { recordRoutingShadow } from "../services/reviewer-routing";
import { scoreJudgmentAgreement } from "../review/judgment-agreement";
import { createInstallationToken } from "../github/app";
import type { AgentActionMode } from "../settings/agent-execution";
import { buildAiReviewDiff } from "../review/review-diff";
Expand Down Expand Up @@ -889,6 +890,10 @@ export async function runAiReviewForAdvisory(
// the REAL reviewer identities and the REAL system prompt into DecisionRecord instead of hardcoding null.
const aiJudgmentModelIds = parsedReviewModelIds(result.reviewDiagnostics ?? []);
const aiJudgmentPromptDigest = result.systemPromptDigest;
// #8834: inter-run agreement over the stances this review ALREADY produced (#8229's reviewerVotes) —
// zero additional AI spend. Computed once and attached to whichever AI-judgment finding is built below,
// so the decision record carries a per-decision confidence signal for the calibration set (#8835).
const aiJudgmentAgreement = (verbalizedConfidence: number) => scoreJudgmentAgreement(result.reviewerVotes, verbalizedConfidence);
if (result.consensusDefect) {
findings.push({
code: "ai_consensus_defect",
Expand All @@ -913,6 +918,7 @@ export async function runAiReviewForAdvisory(
confidence: result.consensusDefect.confidence,
modelIds: aiJudgmentModelIds,
promptDigest: aiJudgmentPromptDigest,
agreement: aiJudgmentAgreement(result.consensusDefect.confidence),
});
} else if (result.split) {
// The reviewers DISAGREED — exactly one flagged a blocking defect. reviewbot's quorum treats any reviewer
Expand Down Expand Up @@ -940,6 +946,10 @@ export async function runAiReviewForAdvisory(
: {}),
modelIds: aiJudgmentModelIds,
promptDigest: aiJudgmentPromptDigest,
// A split IS the disagreement case: the stances differ, so agreement scores strictly below unanimity
// and the recorded confidence falls with it. #8834's "disagreement routes to hold" is already this
// finding's existing behavior via the confidence floor; this measures it rather than re-routing it.
agreement: aiJudgmentAgreement(result.splitConfidence ?? 1),
});
} else if (result.inconclusive) {
// Fail-CLOSED (#ai-fail-closed): block-mode AI could not return a usable verdict. Hold the PR for a human
Expand Down
23 changes: 19 additions & 4 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,7 @@ import {
import { AI_JUDGMENT_BLOCKER_CODES } from "../rules/advisory";
import { computeSalvageabilityForTarget } from "../review/salvageability-wire";
import { deriveDecisionReasonCode, persistDecisionReplayInputForGate } from "../review/decision-replay";
import { isWithinFreshRebaseWindow, type DecisionClockCapture } from "../review/staleness-clock";
import { recordVerdictFlip } from "../review/verdict-flip-store";
import { resolveAutomaticCloseConfidence } from "../review/risk-control-wire";
import { maybeApplyCloseAuditHoldout } from "../review/close-audit-holdout";
Expand Down Expand Up @@ -3175,6 +3176,12 @@ async function runAgentMaintenancePlanAndExecute(
},
): Promise<void> {
const { installationId, repoFullName, pr, settings, otherOpenPullRequests, deliveryId, gate } = args;
// #9028: ONE wall-clock read for this whole decision pass, recorded into the replay input below and passed
// to every clock-dependent gate rule (today `gate.requireFreshRebaseWindow` via maybeForceFreshRebase)
// instead of each of them calling Date.now() independently. Two rules reading the clock at two different
// moments cannot both be replayed from one recorded instant — and an unrecorded instant is an unrecorded
// decision INPUT, which is exactly what makes a time-dependent decision unreplayable.
const decisionClock: DecisionClockCapture = { nowMs: Date.now() };

// Convergence safety: feed the planner the PR's changed paths + the repo's hard-guardrail globs so guarded
// paths force manual review, and flag owner-authored PRs so they are never auto-closed (standing rule).
Expand Down Expand Up @@ -3756,18 +3763,22 @@ async function runAgentMaintenancePlanAndExecute(
modelIds: aiJudgment?.modelIds ?? null,
promptDigest: aiJudgment?.promptDigest ?? null,
aiConfidence: aiJudgment?.confidence ?? null,
// #8834: the inter-run agreement signal computed at review time (ai-review-orchestration.ts) and
// carried on the finding, so the record captures HOW REPRODUCIBLY the judgment was reached, not just
// what the model claimed. null for a rule-only decision, exactly like the fields above.
aiAgreement: aiJudgment?.agreement ?? null,
salvageability,
// #9135: legible on the record's own face — see maybeApplyCloseAuditHoldout's doc comment.
divertedByHoldout: closeAuditHoldout?.diverted ?? false,
});
const recordId = await persistDecisionRecord(env, record, recordDigest);
// #8838: persist the evaluation's own exact inputs beside the record (PRIVATE sibling, migration 0181)
// #8838: persist the evaluation's own exact inputs beside the record (PRIVATE sibling, migration 0182)
// so the replay harness can re-derive this decision bit-exactly. Best-effort, like the record itself;
// the no-replay no-op (synthetic content-lane/bridge evaluations) lives inside the helper. Keyed to the
// id persistDecisionRecord actually wrote (#9123: a supersession at the same head gets a revisioned id,
// not the base one this used to always recompute independently). #9135: the holdout outcome rides along
// so `holdout_consistency` has something to check the public record against.
if (recordId !== null) await persistDecisionReplayInputForGate(env, recordId, gate, policyCloseKind ?? null, closeAuditHoldout ?? null);
if (recordId !== null) await persistDecisionReplayInputForGate(env, recordId, gate, policyCloseKind ?? null, closeAuditHoldout ?? null, decisionClock);
}
// #2349 (PR 1): additive per-contributor calibration data, gated identically to recordNativeGateDecision
// above -- see src/review/contributor-calibration.ts's doc comment. Currently write-only; nothing reads
Expand Down Expand Up @@ -3897,6 +3908,7 @@ async function runAgentMaintenancePlanAndExecute(
token,
admissionKey,
deliveryId,
nowMs: decisionClock.nowMs,
}))
) {
return;
Expand Down Expand Up @@ -4806,17 +4818,20 @@ async function maybeForceFreshRebase(
token: string | undefined;
admissionKey: GitHubRateLimitAdmissionKey | undefined;
deliveryId: string;
// #9028: the decision pass's single captured instant — this rule reads it instead of the clock, so the
// window comparison is replayable from `decision_replay_inputs.replay_json`.
nowMs: number;
},
): Promise<boolean> {
const { installationId, repoFullName, pr, settings, windowMinutes, baseRef, token, admissionKey, deliveryId } = args;
const { installationId, repoFullName, pr, settings, windowMinutes, baseRef, token, admissionKey, deliveryId, nowMs } = args;
/* v8 ignore next -- structurally unreachable: the caller only invokes this after confirming
* (liveMergeState ?? pr.mergeableState) === "clean", which GitHub can never compute for a PR with no
* head commit; the null check is belt-and-suspenders against the field's optional TS type. */
if (!pr.headSha) return false;
const advancedAt = await fetchLiveBaseBranchAdvancedAt(env, repoFullName, baseRef, token, admissionKey);
if (!advancedAt) return false; // fail-open: unreadable base commit -> no forced rebase
const advancedAtMs = Date.parse(advancedAt);
if (!Number.isFinite(advancedAtMs) || Date.now() - advancedAtMs >= windowMinutes * 60_000) return false;
if (!isWithinFreshRebaseWindow({ baseAdvancedAtMs: advancedAtMs, windowMinutes, nowMs })) return false;

const countKey = freshRebaseForceCountKey(repoFullName, pr.number);
const storedCount = Number(await getTransientKey(env, countKey));
Expand Down
12 changes: 10 additions & 2 deletions src/review/decision-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
import { errorMessage, nowIso } from "../utils/json";

/** Bump when the record's FIELD SET changes meaning — consumers compare records only within a version. */
export const DECISION_RECORD_SCHEMA_VERSION = "4"; // v4 (#9124/#9135): configDigest digests the resolved policy (+ settingsDigest split out), promptDigest digests the actual sent prompt, modelId -> modelIds (real identities), ciState populated, + divertedByHoldout; v3 (#8962): + salvageability {score, factors}; v2 (#8834): + aiConfidence, model/prompt commitments
export const DECISION_RECORD_SCHEMA_VERSION = "5"; // v5 (#8834): + aiAgreement (inter-run agreement folded with the verbalized confidence); v4 (#9124/#9135): configDigest digests the resolved policy (+ settingsDigest split out), promptDigest digests the actual sent prompt, modelId -> modelIds (real identities), ciState populated, + divertedByHoldout; v3 (#8962): + salvageability {score, factors}; v2 (#8834): + aiConfidence, model/prompt commitments

/**
* Canonical JSON: recursively key-sorted, no insignificant whitespace — the ONE serialization every digest
Expand Down Expand Up @@ -118,6 +118,12 @@ export type DecisionRecord = {
* defect / split), null when no AI judgment contributed. Persisted so every decision joins the
* risk-control calibration set (#8835) with its confidence attached. */
aiConfidence: number | null;
/** #8834: the per-decision confidence signal — inter-run agreement across the reviewer stances that
* produced the AI judgment, folded together with that judgment's verbalized confidence (see
* src/review/judgment-agreement.ts). `aiConfidence` above records what the model SAID; this records how
* reproducibly the reviewers reached it, which is the input a calibrated abstention threshold (#8835)
* needs. null when no AI judgment contributed, and for every record predating v5. */
aiAgreement: { agreement: number; confidence: number; sampleCount: number; uncorroborated: boolean } | null;
/** #8962: the deterministic salvageability score + its named factors when an AI judgment shaped the
* decision — the second-axis evidence for auditing the close/hold boundary. null for rule-only decisions
* (and for reconstructed/backfilled records predating v3). */
Expand All @@ -135,12 +141,13 @@ export type DecisionRecord = {
/** Assemble the record and its own content digest. PURE given pre-computed digests. Normalizes the
* optional-shaped caller fields (undefined -> null) HERE so call sites carry no fallback arms of their own. */
export async function buildDecisionRecord(
input: Omit<DecisionRecord, "schemaVersion" | "decidedAt" | "gatePack" | "ciState" | "baseSha" | "aiConfidence" | "salvageability" | "settingsDigest" | "divertedByHoldout"> & {
input: Omit<DecisionRecord, "schemaVersion" | "decidedAt" | "gatePack" | "ciState" | "baseSha" | "aiConfidence" | "aiAgreement" | "salvageability" | "settingsDigest" | "divertedByHoldout"> & {
decidedAt?: string;
gatePack?: string | null | undefined;
ciState?: string | null | undefined;
baseSha?: string | null | undefined;
aiConfidence?: number | null | undefined;
aiAgreement?: { agreement: number; confidence: number; sampleCount: number; uncorroborated: boolean } | null | undefined;
salvageability?: { score: number; factors: string[] } | null | undefined;
settingsDigest?: string | null | undefined;
divertedByHoldout?: boolean | undefined;
Expand All @@ -154,6 +161,7 @@ export async function buildDecisionRecord(
ciState: input.ciState ?? null,
baseSha: input.baseSha ?? null,
aiConfidence: input.aiConfidence ?? null,
aiAgreement: input.aiAgreement ?? null,
salvageability: input.salvageability ?? null,
settingsDigest: input.settingsDigest ?? null,
divertedByHoldout: input.divertedByHoldout ?? false,
Expand Down
Loading
Loading