Skip to content

Commit 75e6f42

Browse files
authored
fix(review): make review.selftune: false opt-out absolute for the breaker too (#6995)
The accuracy circuit-breaker (runSelfTuneBreaker) had no per-repo opt-out at all, unlike its sibling selfTuneRepos() (the routine tuning pass), which already correctly excludes a repo whose .loopover.yml sets review.selftune: false. A repo could opt out of routine tuning yet still have its gate mode forced into holdonly/closehold by the breaker. Per that flag's own documented intent ("excludes this repo from the tuning pass"), the opt-out is now absolute: an opted-out repo is excluded from every part of self-tune, both the merge and close breakers, in both the plain and miner-scoped passes, and in both directions -- the breaker can neither newly engage a hold for an opted-out repo nor auto-clear an already-engaged one. A manifest-load error fails open (repo stays included), matching selfTuneRepos()'s same fail-safe precedent. Closes #6803
1 parent abc89fc commit 75e6f42

2 files changed

Lines changed: 142 additions & 8 deletions

File tree

src/review/outcomes-wire.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import { recordAuditEvent } from "../db/repositories";
2727
import { tryEnqueueDecisionPackRebuild } from "../services/decision-pack";
2828
import { incr } from "../selfhost/metrics";
29+
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
2930
import type { GitHubWebhookPayload } from "../types";
3031
import { errorMessage, nowIso } from "../utils/json";
3132
import {
@@ -537,6 +538,52 @@ function minerBreakerScope(project: string): string {
537538
return `${project}${MINER_BREAKER_SCOPE_SUFFIX}`;
538539
}
539540

541+
/** Strip the `:miner` scope suffix a project key MAY carry, so both `listEngagedProjectScopes`'s scoped keys
542+
* and `GateEvalReport.rows[].project` resolve to the same real repo full name the opt-out check needs. */
543+
function baseProjectName(project: string): string {
544+
return project.endsWith(MINER_BREAKER_SCOPE_SUFFIX) ? project.slice(0, -MINER_BREAKER_SCOPE_SUFFIX.length) : project;
545+
}
546+
547+
/** #6803: the accuracy circuit-breaker (this whole pass) previously had no per-repo opt-out at all, unlike its
548+
* sibling `selfTuneRepos()` (selftune-wire.ts), which already correctly excludes a repo whose `.loopover.yml`
549+
* sets `review.selftune: false` from the routine tuning pass. Per that flag's own documented intent
550+
* ("excludes this repo from the tuning pass"), the opt-out is ABSOLUTE: it excludes a repo from every part of
551+
* self-tune, not just the routine pass, so the breaker must never engage OR auto-clear holdonly/closehold for
552+
* an opted-out repo either -- a manifest-load error fails OPEN (repo stays included), matching
553+
* `selfTuneRepos()`'s own same fail-safe precedent, since a settings-read blip must never silently widen what
554+
* the breaker acts on. */
555+
async function isSelfTuneOptedOut(env: Env, repoFullName: string): Promise<boolean> {
556+
const manifest = await loadRepoFocusManifest(env, repoFullName).catch(() => null);
557+
return manifest?.review.selftune === false;
558+
}
559+
560+
/** Filter a {@link GateEvalReport}'s rows AND a scope's already-engaged flag list down to the projects that are
561+
* NOT self-tune-opted-out, resolving each project key's real repo name first (`:miner`-suffixed keys included)
562+
* -- the two lists this module's every downstream computation (engage candidates, clear candidates) derives
563+
* from, so filtering both here is sufficient for the opt-out to be absolute. */
564+
async function excludeSelfTuneOptedOut(
565+
env: Env,
566+
report: GateEvalReport,
567+
engagedHoldonly: readonly string[],
568+
engagedClosehold: readonly string[],
569+
): Promise<{ report: GateEvalReport; engagedHoldonly: string[]; engagedClosehold: string[] }> {
570+
const candidateProjects = new Set([
571+
...report.rows.map((row) => baseProjectName(row.project)),
572+
...engagedHoldonly.map(baseProjectName),
573+
...engagedClosehold.map(baseProjectName),
574+
]);
575+
const optedOut = new Set<string>();
576+
for (const repoFullName of candidateProjects) {
577+
if (await isSelfTuneOptedOut(env, repoFullName)) optedOut.add(repoFullName);
578+
}
579+
if (optedOut.size === 0) return { report, engagedHoldonly: [...engagedHoldonly], engagedClosehold: [...engagedClosehold] };
580+
return {
581+
report: { ...report, rows: report.rows.filter((row) => !optedOut.has(baseProjectName(row.project))) },
582+
engagedHoldonly: engagedHoldonly.filter((project) => !optedOut.has(baseProjectName(project))),
583+
engagedClosehold: engagedClosehold.filter((project) => !optedOut.has(baseProjectName(project))),
584+
};
585+
}
586+
540587
/** Run the full engage + auto-clear sequence for one {@link GateEvalReport} (either the plain project-keyed
541588
* report or a miner-rescoped one). `eventPrefix` namespaces the emitted log events (`""` for the existing
542589
* human/mixed pass, `"miner_"` for the #2352 miner-scoped pass) so an operator can tell which population
@@ -638,22 +685,23 @@ export async function runSelfTuneBreaker(env: Env): Promise<void> {
638685
const engagedScopes = await listEngagedProjectScopes(env);
639686
const isMinerScope = (project: string): boolean => project.endsWith(MINER_BREAKER_SCOPE_SUFFIX);
640687

641-
await runBreakerPassForReport(
642-
flags,
688+
// #6803: exclude every self-tune-opted-out repo from both passes -- see excludeSelfTuneOptedOut's own doc
689+
// comment for why this must happen before engage/clear candidates are computed, not filtered after.
690+
const plainPass = await excludeSelfTuneOptedOut(
691+
env,
643692
report,
644693
engagedScopes.holdonly.filter((project) => !isMinerScope(project)),
645694
engagedScopes.closehold.filter((project) => !isMinerScope(project)),
646-
nowMs,
647-
"",
648695
);
649-
await runBreakerPassForReport(
650-
flags,
696+
const minerPass = await excludeSelfTuneOptedOut(
697+
env,
651698
minerReport,
652699
engagedScopes.holdonly.filter(isMinerScope),
653700
engagedScopes.closehold.filter(isMinerScope),
654-
nowMs,
655-
"miner_",
656701
);
702+
703+
await runBreakerPassForReport(flags, plainPass.report, plainPass.engagedHoldonly, plainPass.engagedClosehold, nowMs, "");
704+
await runBreakerPassForReport(flags, minerPass.report, minerPass.engagedHoldonly, minerPass.engagedClosehold, nowMs, "miner_");
657705
} catch (error) {
658706
console.warn(
659707
JSON.stringify({

test/unit/outcomes-wire.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type PlannedAgentAction,
1919
} from "../../src/settings/agent-actions";
2020
import { recordAuditEvent } from "../../src/db/repositories";
21+
import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader";
2122
import type { GitHubPullRequestPayload } from "../../src/types";
2223
import { createTestEnv } from "../helpers/d1";
2324

@@ -774,6 +775,91 @@ describe("runSelfTuneBreaker — reads recorded pr_outcome ground truth + engage
774775
expect(await isCloseHoldOnly(env, "owner/repo")).toBe(false); // close breaker auto-cleared
775776
});
776777

778+
describe("#6803: review.selftune: false opt-out is absolute for the breaker too, not just the routine tuning pass", () => {
779+
it("does NOT engage the merge or close breaker for an opted-out repo, even with data that would otherwise trip both", async () => {
780+
const env = createTestEnv();
781+
await upsertRepoFocusManifest(env, "owner/opted-out", { review: { selftune: false } });
782+
// Same shape as the plain ENGAGES tests above -- would trip both breakers if this repo weren't opted out.
783+
for (let i = 0; i < 4; i += 1) await seedDecisionAndOutcome(env, "owner/opted-out", i, "merge", "merged");
784+
for (let i = 4; i < 12; i += 1) await seedDecisionAndOutcome(env, "owner/opted-out", i, "merge", "closed");
785+
for (let i = 12; i < 16; i += 1) await seedDecisionAndOutcome(env, "owner/opted-out", i, "close", "closed");
786+
for (let i = 16; i < 24; i += 1) await seedDecisionAndOutcome(env, "owner/opted-out", i, "close", "merged");
787+
788+
await runSelfTuneBreaker(env);
789+
790+
expect(await isHoldOnly(env, "owner/opted-out")).toBe(false);
791+
expect(await isCloseHoldOnly(env, "owner/opted-out")).toBe(false);
792+
});
793+
794+
it("does NOT auto-clear an already-engaged flag for an opted-out repo, even with fully recovered precision -- the opt-out is absolute, not one-directional", async () => {
795+
const env = createTestEnv();
796+
const flags = createFlagStore(env);
797+
await flags.setFlag("holdonly:owner/opted-out", true);
798+
await flags.setFlag("closehold:owner/opted-out", true);
799+
await env.DB.prepare(
800+
"UPDATE system_flags SET updated_at = datetime('now', '-2 days') WHERE key IN ('holdonly:owner/opted-out', 'closehold:owner/opted-out')",
801+
).run();
802+
await upsertRepoFocusManifest(env, "owner/opted-out", { review: { selftune: false } });
803+
// Fully recovered precision -- would auto-clear both breakers (per the AUTO-CLEARS test above) if this
804+
// repo weren't opted out.
805+
for (let i = 0; i < 12; i += 1) await seedDecisionAndOutcome(env, "owner/opted-out", i, "merge", "merged");
806+
for (let i = 12; i < 24; i += 1) await seedDecisionAndOutcome(env, "owner/opted-out", i, "close", "closed");
807+
808+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
809+
await runSelfTuneBreaker(env);
810+
log.mockRestore();
811+
812+
expect(await isHoldOnly(env, "owner/opted-out")).toBe(true);
813+
expect(await isCloseHoldOnly(env, "owner/opted-out")).toBe(true);
814+
});
815+
816+
it("also excludes an opted-out repo from the miner-scoped pass (#2352)", async () => {
817+
const env = createTestEnv();
818+
await upsertRepoFocusManifest(env, "owner/opted-out", { review: { selftune: false } });
819+
// Miner-authored rows (miner_authored=1), same shape as the #2352 ENGAGES test: 33% precision, would
820+
// otherwise trip the holdonly:owner/opted-out:miner flag.
821+
for (let i = 0; i < 4; i += 1) {
822+
await env.DB.prepare(
823+
"INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, miner_authored, created_at) VALUES (?, ?, ?, 'gate_decision', 'merge', 'gittensory-native', ?, NULL, 1, CURRENT_TIMESTAMP)",
824+
)
825+
.bind(`gd:m:owner/opted-out#${i}`, "owner/opted-out", `owner/opted-out#${i}`, `sha${i}`)
826+
.run();
827+
await env.DB.prepare(
828+
"INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, created_at) VALUES (?, ?, ?, 'pr_outcome', 'merged', 'gittensory-native', NULL, NULL, CURRENT_TIMESTAMP)",
829+
)
830+
.bind(`po:m:owner/opted-out#${i}`, "owner/opted-out", `owner/opted-out#${i}`)
831+
.run();
832+
}
833+
for (let i = 4; i < 12; i += 1) {
834+
await env.DB.prepare(
835+
"INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, miner_authored, created_at) VALUES (?, ?, ?, 'gate_decision', 'merge', 'gittensory-native', ?, NULL, 1, CURRENT_TIMESTAMP)",
836+
)
837+
.bind(`gd:m:owner/opted-out#${i}`, "owner/opted-out", `owner/opted-out#${i}`, `sha${i}`)
838+
.run();
839+
await env.DB.prepare(
840+
"INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, created_at) VALUES (?, ?, ?, 'pr_outcome', 'closed', 'gittensory-native', NULL, NULL, CURRENT_TIMESTAMP)",
841+
)
842+
.bind(`po:m:owner/opted-out#${i}`, "owner/opted-out", `owner/opted-out#${i}`)
843+
.run();
844+
}
845+
846+
await runSelfTuneBreaker(env);
847+
848+
expect(await isHoldOnly(env, "owner/opted-out:miner")).toBe(false);
849+
});
850+
851+
it("does not opt out an unrelated repo (the exclusion is per-repo, not global)", async () => {
852+
const env = createTestEnv();
853+
await upsertRepoFocusManifest(env, "owner/opted-out", { review: { selftune: false } });
854+
for (let i = 0; i < 4; i += 1) await seedDecisionAndOutcome(env, "owner/still-tuned", i, "merge", "merged");
855+
for (let i = 4; i < 12; i += 1) await seedDecisionAndOutcome(env, "owner/still-tuned", i, "merge", "closed");
856+
857+
await runSelfTuneBreaker(env);
858+
859+
expect(await isHoldOnly(env, "owner/still-tuned")).toBe(true);
860+
});
861+
});
862+
777863
it("never throws (fails safe) even when review_audit reads blow up", async () => {
778864
const env = createTestEnv();
779865
const realPrepare = env.DB.prepare.bind(env.DB);

0 commit comments

Comments
 (0)