Skip to content

Commit 6c1870c

Browse files
authored
fix(orb): stop a conclusion-derived verdict from overwriting a recorded close (#8826)
Both gate_decision writers key the same deterministic row id (gate:<source>:<project>#<pr>@<sha>) with ON CONFLICT DO UPDATE, so the last writer wins. One caller records the ACTUAL disposition the bot acted on; the other derives the verdict from the gate-check conclusion alone, where success maps to merge. On a PR the bot closed for a downstream reason (CI failure, policy) the conclusion-only writer runs last and clobbers the real close with a merge. On #5861 the close landed at 20:23:31 and the contradicting merge verdict was written at 20:23:44 -- 13 seconds after the PR was already closed. Fleet calibration reads the latest gate_decision as the gate's prediction, so every such row is scored as a merge prediction that ended closed: a false positive that never happened. Measured on the live self-host, 59 rows carry a verdict timestamped after the close action it contradicts, out of 210 in that class -- biasing published accuracy DOWNWARD, opposite to the reversal under-counting in #8823. Advances #8825 (the recording half; scoring policy closes as their own class is the remaining part). The DO UPDATE now skips when the incoming write is conclusion-derived and the stored decision is already 'close'. A close is a terminal action that already happened and no later conclusion can un-close it. An explicit action still replaces it, and non-close rows keep latest-finalize-wins.
1 parent df370e4 commit 6c1870c

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

src/review/parity-wire.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,16 +163,29 @@ export async function recordNativeGateDecision(
163163
const targetId = `${project}#${input.pullNumber}`;
164164
const summary = input.reasonCode ? input.reasonCode.slice(0, 200) : null;
165165
const minerAuthored = input.minerAuthored === true ? 1 : 0;
166+
// #8825: whether this verdict is the ACTUAL disposition the bot acted on (`input.action` supplied by the
167+
// disposition-aware caller) or merely DERIVED from the gate-check conclusion. Both callers write the same
168+
// deterministic row id below, so without this distinction the conclusion-derived write clobbers the real one.
169+
const derivedFromConclusion = input.action === undefined ? 1 : 0;
166170
try {
167171
// Deterministic id per (source, project, pr, sha): a re-run at the SAME commit REPLACES its prior decision
168172
// (the latest finalize wins), while a new commit gets its own row. event_type/source default in the schema
169173
// but are written explicitly for clarity.
174+
//
175+
// #8825 — the DO UPDATE is guarded so a conclusion-derived verdict can never overwrite a recorded `close`.
176+
// A gate conclusion of "success" maps to `merge` (nativeGateActionFromConclusion), and the conclusion-only
177+
// caller runs AFTER the disposition-aware one on a PR the bot closed for a downstream reason (CI failure,
178+
// policy). That wrote `merge` over the real `close`, sometimes seconds after the PR was already closed --
179+
// measured on the live self-host, 59 rows recorded a verdict timestamped AFTER the close action it
180+
// contradicted, and calibration scored every one as a merge prediction that ended closed. A close is a
181+
// terminal action that already happened; no later conclusion can un-close it, so the older row wins.
170182
await env.DB.prepare(
171183
`INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, miner_authored, created_at)
172184
VALUES (?, ?, ?, 'gate_decision', ?, ?, ?, ?, ?, ?)
173-
ON CONFLICT(id) DO UPDATE SET decision = excluded.decision, summary = excluded.summary, miner_authored = excluded.miner_authored, created_at = excluded.created_at`,
185+
ON CONFLICT(id) DO UPDATE SET decision = excluded.decision, summary = excluded.summary, miner_authored = excluded.miner_authored, created_at = excluded.created_at
186+
WHERE NOT (? = 1 AND review_audit.decision = 'close')`,
174187
)
175-
.bind(`gate:${LOOPOVER_NATIVE_SOURCE}:${targetId}@${input.headSha}`, project, targetId, action, LOOPOVER_NATIVE_SOURCE, input.headSha, summary, minerAuthored, nowIso())
188+
.bind(`gate:${LOOPOVER_NATIVE_SOURCE}:${targetId}@${input.headSha}`, project, targetId, action, LOOPOVER_NATIVE_SOURCE, input.headSha, summary, minerAuthored, nowIso(), derivedFromConclusion)
176189
.run();
177190
} catch (error) {
178191
// Telemetry must never break finalization.

test/unit/parity-wire.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,38 @@ describe("recordNativeGateDecision — flag-gated SHADOW recording into review_a
147147
expect(rows[0]).toMatchObject({ miner_authored: 1 });
148148
});
149149

150+
it("#8825: a conclusion-derived verdict NEVER overwrites a recorded close (the terminal action already happened)", async () => {
151+
const env = createTestEnv({ LOOPOVER_REVIEW_PARITY_AUDIT: "true" });
152+
// The disposition-aware caller records the real action: the bot closed this PR (CI failure / policy).
153+
await recordNativeGateDecision(env, { project: "owner/repo", pullNumber: 7, headSha: "abc123", conclusion: "success", action: "close", reasonCode: "ci_failing" });
154+
// The conclusion-only caller then finalizes with a "success" conclusion, which maps to merge. Before this
155+
// fix that clobbered the close and calibration scored the PR as a merge prediction that ended closed.
156+
await recordNativeGateDecision(env, { project: "owner/repo", pullNumber: 7, headSha: "abc123", conclusion: "success", reasonCode: "success" });
157+
158+
const rows = await rawAll(env, "SELECT * FROM review_audit");
159+
expect(rows).toHaveLength(1);
160+
expect(rows[0]).toMatchObject({ decision: "close", summary: "ci_failing" });
161+
});
162+
163+
it("#8825: an EXPLICIT action still replaces a recorded close — only conclusion-derived writes are blocked", async () => {
164+
const env = createTestEnv({ LOOPOVER_REVIEW_PARITY_AUDIT: "true" });
165+
await recordNativeGateDecision(env, { project: "owner/repo", pullNumber: 7, headSha: "abc123", conclusion: "success", action: "close", reasonCode: "ci_failing" });
166+
await recordNativeGateDecision(env, { project: "owner/repo", pullNumber: 7, headSha: "abc123", conclusion: "success", action: "merge", reasonCode: "recovered" });
167+
168+
const rows = await rawAll(env, "SELECT * FROM review_audit");
169+
expect(rows[0]).toMatchObject({ decision: "merge", summary: "recovered" });
170+
});
171+
172+
it("#8825: a conclusion-derived verdict still updates a non-close row (hold/merge stay latest-wins)", async () => {
173+
const env = createTestEnv({ LOOPOVER_REVIEW_PARITY_AUDIT: "true" });
174+
await recordNativeGateDecision(env, { project: "owner/repo", pullNumber: 7, headSha: "abc123", conclusion: "failure", reasonCode: "guardrail_hold" });
175+
await recordNativeGateDecision(env, { project: "owner/repo", pullNumber: 7, headSha: "abc123", conclusion: "success", reasonCode: "success" });
176+
177+
const rows = await rawAll(env, "SELECT * FROM review_audit");
178+
expect(rows).toHaveLength(1);
179+
expect(rows[0]).toMatchObject({ decision: "merge", summary: "success" });
180+
});
181+
150182
it("a re-run at the SAME commit REPLACES the prior decision (latest finalize wins, no duplicate)", async () => {
151183
const env = createTestEnv({ LOOPOVER_REVIEW_PARITY_AUDIT: "true" });
152184
await recordNativeGateDecision(env, { project: "owner/repo", pullNumber: 7, headSha: "abc123", conclusion: "success", reasonCode: "all_clear" });

0 commit comments

Comments
 (0)