Skip to content

Commit a72b4ba

Browse files
authored
fix(agent): deny approve/merge when a live manual-review hold exists (#3472) (#3483)
Two independent plan-and-execute passes can evaluate the same PR head (a webhook re-review racing a regate-repair/sweep pass): the loser of the AI-review lock holds the PR for manual review (label + assign), while the winner's own slower AI review later resolves cleanly and stages approve/merge from a plan-time snapshot that predates the hold. Neither the per-PR actuation lock nor the executor's freshness guard checks the PR's live labels, so the hold was silently bypassed. Add a live guard immediately before approve/merge that re-checks the same freshness fetch for the configured manual-review label and denies the action if it's present -- self-scoping to the same head (a new commit already fails freshness first) with no new locking or persistence required. Closes #3482
1 parent b5d2956 commit a72b4ba

10 files changed

Lines changed: 126 additions & 10 deletions

src/github/pr-freshness.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export type PullRequestFreshness =
1717
status: "current";
1818
liveHeadSha: string | null;
1919
liveState: string | null;
20+
// Live label names off the SAME fetch that proved this head is current — lets a caller re-check a
21+
// disposition label (e.g. a manual-review hold) against ground truth immediately before a mutation,
22+
// without a second GitHub call (#3472 split-brain).
23+
liveLabels: string[];
2024
}
2125
| {
2226
status: "stale";
@@ -41,7 +45,7 @@ export function reviewedPullRequestHeadSha(
4145
}
4246

4347
export function classifyPullRequestFreshness(
44-
live: Pick<GitHubPullRequestPayload, "state" | "head" | "draft"> | null | undefined,
48+
live: Pick<GitHubPullRequestPayload, "state" | "head" | "draft" | "labels"> | null | undefined,
4549
expectedHeadSha: string | null | undefined,
4650
options?: PullRequestFreshnessOptions,
4751
): PullRequestFreshness {
@@ -85,7 +89,8 @@ export function classifyPullRequestFreshness(
8589
if (options?.requireDraft && live.draft !== true) {
8690
return { status: "stale", reason: "no_longer_draft", expectedHeadSha: expected, liveHeadSha, liveState };
8791
}
88-
return { status: "current", liveHeadSha, liveState };
92+
const liveLabels = (live.labels ?? []).map((label) => label.name).filter((name): name is string => Boolean(name));
93+
return { status: "current", liveHeadSha, liveState, liveLabels };
8994
}
9095

9196
export async function fetchPullRequestFreshness(

src/queue/processors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2849,6 +2849,9 @@ async function runAgentMaintenancePlanAndExecute(
28492849
// merge or a CI-driven close) must honor the same configured expectedCiContexts this plan was evaluated
28502850
// against, or the two can disagree on ciState.
28512851
expectedCiContexts: settings.expectedCiContexts,
2852+
// #3472 split-brain: the executor's own live manual-review hold guard (immediately before approve/merge)
2853+
// must check the SAME configured label the planner itself resolves labels.manualReview from.
2854+
manualReviewLabel: settings.manualReviewLabel,
28522855
},
28532856
breakerOnPlan,
28542857
);

src/services/agent-action-executor.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { closeIssue, closePullRequest, createIssueComment, createPullRequestRevi
2424
import { fetchPullRequestFreshness, pullRequestFreshnessDetail } from "../github/pr-freshness";
2525
import { isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
2626
import { boundStructuredCloseReasonsForPersistence, buildAgentActionAudit, formatAgentPermissionDenial, isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness, type AgentActionMode } from "../settings/agent-execution";
27-
import type { PlannedAgentAction } from "../settings/agent-actions";
27+
import { AGENT_LABEL_NEEDS_REVIEW, type PlannedAgentAction } from "../settings/agent-actions";
2828
import type { AgentActionClass, AgentPendingActionParams, AutonomyLevel, AutonomyPolicy } from "../types";
2929
import { errorMessage } from "../utils/json";
3030
import {
@@ -175,6 +175,12 @@ export type AgentActionExecutionContext = {
175175
// required-only view was already clean). Absent/undefined ⇒ fold-all mode, unchanged from before this field
176176
// existed.
177177
expectedCiContexts?: ReadonlyArray<string> | null | undefined;
178+
// settings.manualReviewLabel (#3472 split-brain), resolved by the CALLER (same "the executor has no settings
179+
// access" shape as expectedCiContexts above): the approve/merge live label guard (step 7b below) needs the
180+
// SAME configured label name the planner itself resolves labels.manualReview from (agent-actions.ts), so a
181+
// custom label name is honored instead of only ever checking the literal default. `null` explicitly disables
182+
// the manual-review label (and this guard with it); absent/undefined uses the default AGENT_LABEL_NEEDS_REVIEW.
183+
manualReviewLabel?: string | null | undefined;
178184
};
179185

180186
export type ModerationContextSettings = {
@@ -215,7 +221,8 @@ function coupledCloseOutcome(planned: PlannedAgentAction[], outcomes: AgentActio
215221
* through the SAME deny-toward-safety gate stack:
216222
* pause (#776 kill-switch) → current autonomy → dry_run → approval (auto_with_approval → #779 queue) →
217223
* write-permission (#775, checked BEFORE any GitHub call so a known-denied write never spends freshness/live-CI
218-
* API budget) → label/close correlation → freshness → live-CI re-verification → the real mutation.
224+
* API budget) → label/close correlation → freshness → manual-review hold (approve/merge only, #3472) →
225+
* live-CI re-verification → the real mutation.
219226
* Only `live` mode performs a real mutation; `dry_run` records what it WOULD do. Every path writes one
220227
* `agent.action.<class>` audit record (#776) EXCEPT a write-permission denial repeated within
221228
* PR_WRITE_DENIAL_COOLDOWN_MS of the last one for the same installation/repo/PR/action-class, which is counted but
@@ -332,6 +339,23 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE
332339
await audit("denied", `${pullRequestFreshnessDetail(freshness)} — action not executed`);
333340
continue;
334341
}
342+
// 7b) Manual-review hold guard (#3472 split-brain): approve/merge is planned from a snapshot (the DB's
343+
// cached pr.labels, or a plan staged earlier for approval) that can predate a SIBLING pass for this exact
344+
// PR/head publishing a manual-review hold (label + assign) while THIS pass's own — possibly much slower —
345+
// AI review or gate evaluation was still in flight. The per-PR actuation lock (#2129) only serializes each
346+
// pass's plan-and-execute critical section; it does not make one pass aware of another's disposition, and
347+
// the stored PR row can itself lag the live label write by a full webhook round-trip. Re-check the SAME
348+
// live fetch that just proved this head is current (no extra GitHub call) for the configured manual-review
349+
// label: if present, a hold is standing for this exact head and must not be silently overridden by a
350+
// merit verdict computed before that hold existed. Only a maintainer removing the label, or a new commit
351+
// (which the freshness check above already denies as stale), lifts it.
352+
if (action.actionClass === "approve" || action.actionClass === "merge") {
353+
const manualReviewLabel = ctx.manualReviewLabel === null ? null : (ctx.manualReviewLabel ?? AGENT_LABEL_NEEDS_REVIEW);
354+
if (manualReviewLabel !== null && freshness.liveLabels.some((label) => label.toLowerCase() === manualReviewLabel.toLowerCase())) {
355+
await audit("denied", `manual-review label "${manualReviewLabel}" is present on the live PR — ${action.actionClass} not executed`);
356+
continue;
357+
}
358+
}
335359
}
336360
// 8) Live CI re-verification for a merge or a CI-driven heuristic close (#2128): the CI aggregate that drove
337361
// either decision was read seconds-to-tens-of-seconds earlier, in the planning pass, and the freshness

src/services/agent-approval-queue.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,10 @@ export async function decidePendingAgentAction(env: Env, input: { id: string; de
356356
// executeAgentMaintenanceActions) needs the same configured expectedCiContexts this accept-time
357357
// re-check (above) and the original plan were both evaluated against.
358358
expectedCiContexts: settings.expectedCiContexts,
359+
// #3472 split-brain: a staged approve/merge can sit queued long enough for a SIBLING pass to publish a
360+
// manual-review hold on this same PR/head before the maintainer accepts — the executor's own live guard
361+
// (step 7b of executeAgentMaintenanceActions) needs the configured label to check for.
362+
manualReviewLabel: settings.manualReviewLabel,
359363
},
360364
plan,
361365
);

test/unit/agent-action-executor.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => {
2424
status: "current" as const,
2525
liveHeadSha: args.expectedHeadSha ?? null,
2626
liveState: "open",
27+
liveLabels: [] as string[],
2728
})),
2829
};
2930
});
@@ -100,6 +101,7 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
100101
status: "current",
101102
liveHeadSha: args.expectedHeadSha ?? null,
102103
liveState: "open",
104+
liveLabels: [],
103105
}));
104106
clearInstallationHealthRefreshCooldownForTest();
105107
clearWritePermissionDenialCooldownForTest();
@@ -365,6 +367,60 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
365367
expect(createPullRequestReview).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "APPROVE", "", "reviewed-sha");
366368
});
367369

370+
it("REGRESSION (#3472 split-brain): LIVE merge is denied when the default manual-review label is present on the live PR, even though the plan itself computed a clean merge", async () => {
371+
const env = createTestEnv({});
372+
// Simulates a sibling pass (e.g. a webhook re-review) publishing a manual-review hold WHILE this pass's own
373+
// gate evaluation was still in flight -- the plan below was computed before that hold existed, so only the
374+
// executor's live re-check (not the plan) can catch it.
375+
vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: ["manual-review"] });
376+
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]);
377+
expect(outcomes[0]?.outcome).toBe("denied");
378+
expect(outcomes[0]?.detail).toContain('manual-review label "manual-review" is present on the live PR — merge not executed');
379+
expect(mergePullRequest).not.toHaveBeenCalled();
380+
});
381+
382+
it("REGRESSION (#3472 split-brain): LIVE approve is denied when the default manual-review label is present on the live PR", async () => {
383+
const env = createTestEnv({});
384+
vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: ["manual-review"] });
385+
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [approve]);
386+
expect(outcomes[0]?.outcome).toBe("denied");
387+
expect(outcomes[0]?.detail).toContain('manual-review label "manual-review" is present on the live PR — approve not executed');
388+
expect(createPullRequestReview).not.toHaveBeenCalled();
389+
});
390+
391+
it("LIVE merge proceeds when the live PR carries other labels but not the manual-review hold", async () => {
392+
const env = createTestEnv({});
393+
vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: ["size/L", "gittensor:bug"] });
394+
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]);
395+
expect(outcomes[0]?.outcome).toBe("completed");
396+
expect(mergePullRequest).toHaveBeenCalled();
397+
});
398+
399+
it("honors a CUSTOM configured manualReviewLabel name (case-insensitive) instead of only the literal default", async () => {
400+
const env = createTestEnv({});
401+
vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: ["Needs-Human"] });
402+
const outcomes = await executeAgentMaintenanceActions(env, ctx({ manualReviewLabel: "needs-human" }), [merge]);
403+
expect(outcomes[0]?.outcome).toBe("denied");
404+
expect(outcomes[0]?.detail).toContain('manual-review label "needs-human" is present on the live PR — merge not executed');
405+
});
406+
407+
it("skips the manual-review hold guard entirely when manualReviewLabel is explicitly disabled (null)", async () => {
408+
const env = createTestEnv({});
409+
vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: ["manual-review"] });
410+
const outcomes = await executeAgentMaintenanceActions(env, ctx({ manualReviewLabel: null }), [merge]);
411+
expect(outcomes[0]?.outcome).toBe("completed");
412+
expect(mergePullRequest).toHaveBeenCalled();
413+
});
414+
415+
it("the manual-review hold guard is scoped to approve/merge only -- a label/close/assign action is unaffected by the live manual-review label", async () => {
416+
const env = createTestEnv({});
417+
vi.mocked(fetchPullRequestFreshness).mockResolvedValue({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: ["manual-review"] });
418+
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [label, close]);
419+
expect(outcomes.map((o) => o.outcome)).toEqual(["completed", "completed"]);
420+
expect(ensurePullRequestLabel).toHaveBeenCalled();
421+
expect(closePullRequest).toHaveBeenCalled();
422+
});
423+
368424
it("LIVE heuristic close is denied when live CI has since turned green (#2128)", async () => {
369425
const env = createTestEnv({});
370426
const heuristicClose: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "CI failed", closeComment: "closing", closeKind: "heuristic", closeRequiresCiState: "failed" };
@@ -855,7 +911,7 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
855911
// regression tripwire -- it must NEVER be consumed, because the label reuses the close's already-proven
856912
// outcome instead of re-checking freshness against the now-closed PR (which would read "stale"/closed and
857913
// wrongly deny the label). The toHaveBeenCalledTimes(1) assertion below is what actually proves this.
858-
.mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open" })
914+
.mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: [] })
859915
.mockResolvedValueOnce({ status: "stale", reason: "closed", expectedHeadSha: "sha7", liveHeadSha: "sha7", liveState: "closed" });
860916
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [coupledClose, coupledLabel]);
861917
expect(outcomes.map((o) => o.outcome)).toEqual(["completed", "completed"]);
@@ -969,6 +1025,7 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
9691025
status: "current",
9701026
liveHeadSha: "sha7",
9711027
liveState: "open",
1028+
liveLabels: [],
9721029
})
9731030
.mockResolvedValueOnce({
9741031
status: "stale",
@@ -1113,6 +1170,7 @@ describe("moderation-rules engine escalation (#selfhost-mod-engine)", () => {
11131170
status: "current",
11141171
liveHeadSha: args.expectedHeadSha ?? null,
11151172
liveState: "open",
1173+
liveLabels: [],
11161174
}));
11171175
// clearAllMocks() resets call history but does NOT drain a queued mockRejectedValueOnce/mockResolvedValueOnce
11181176
// left over from an earlier test elsewhere in this file (e.g. the installation-health-refresh tests above

test/unit/agent-approval-queue.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => {
1818
status: "current" as const,
1919
liveHeadSha: args.expectedHeadSha ?? null,
2020
liveState: "open",
21+
liveLabels: [] as string[],
2122
})),
2223
};
2324
});

test/unit/mcp-automation-state.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => {
3434
status: "current" as const,
3535
liveHeadSha: args.expectedHeadSha ?? null,
3636
liveState: "open",
37+
liveLabels: [] as string[],
3738
})),
3839
};
3940
});

test/unit/pr-freshness.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe("PR freshness guards", () => {
1414

1515
it("classifies a matching open head as current", () => {
1616
const result = classifyPullRequestFreshness({ state: "open", head: { sha: "sha1" } }, "sha1");
17-
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open" });
17+
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open", liveLabels: [] });
1818
expect(pullRequestFreshnessDetail(result)).toBe("PR is current");
1919
});
2020

@@ -23,9 +23,26 @@ describe("PR freshness guards", () => {
2323
status: "current",
2424
liveHeadSha: null,
2525
liveState: "open",
26+
liveLabels: [],
2627
});
2728
});
2829

30+
it("carries live label names alongside a current status (#3472 split-brain)", () => {
31+
const result = classifyPullRequestFreshness(
32+
{ state: "open", head: { sha: "sha1" }, labels: [{ name: "manual-review" }, { name: "size/L" }] },
33+
"sha1",
34+
);
35+
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open", liveLabels: ["manual-review", "size/L"] });
36+
});
37+
38+
it("drops nameless label entries when carrying live labels", () => {
39+
const result = classifyPullRequestFreshness(
40+
{ state: "open", head: { sha: "sha1" }, labels: [{ name: "manual-review" }, {}] },
41+
"sha1",
42+
);
43+
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open", liveLabels: ["manual-review"] });
44+
});
45+
2946
it("treats unavailable live state as stale for callers that require proof", () => {
3047
const result = classifyPullRequestFreshness(undefined, "sha1");
3148
expect(result).toMatchObject({ status: "stale", reason: "unavailable", expectedHeadSha: "sha1" });
@@ -115,7 +132,7 @@ describe("PR freshness guards", () => {
115132

116133
it("does not require draft state by default, even when the PR is no longer a draft", () => {
117134
const result = classifyPullRequestFreshness({ state: "open", head: { sha: "sha1" }, draft: false }, "sha1");
118-
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open" });
135+
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open", liveLabels: [] });
119136
});
120137

121138
it("REGRESSION (#2130 follow-up): treats a same-head PR converted back to ready_for_review as stale when the caller requires draft", () => {
@@ -126,7 +143,7 @@ describe("PR freshness guards", () => {
126143

127144
it("treats a still-draft PR as current when the caller requires draft", () => {
128145
const result = classifyPullRequestFreshness({ state: "open", head: { sha: "sha1" }, draft: true }, "sha1", { requireDraft: true });
129-
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open" });
146+
expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open", liveLabels: [] });
130147
});
131148

132149
it("treats a missing draft field as stale when the caller requires draft (fail-safe: only an explicit true counts)", () => {
@@ -158,10 +175,10 @@ describe("PR freshness guards", () => {
158175
expect(reviewedPullRequestHeadSha(" AbC123 ", "fallback")).toBe("abc123");
159176
expect(
160177
classifyPullRequestFreshness({ state: "open", head: { sha: "AbC123" } }, "abc123"),
161-
).toEqual({ status: "current", liveHeadSha: "abc123", liveState: "open" });
178+
).toEqual({ status: "current", liveHeadSha: "abc123", liveState: "open", liveLabels: [] });
162179
expect(
163180
classifyPullRequestFreshness({ state: "open", head: { sha: "abc123" } }, " ABC123 "),
164-
).toEqual({ status: "current", liveHeadSha: "abc123", liveState: "open" });
181+
).toEqual({ status: "current", liveHeadSha: "abc123", liveState: "open", liveLabels: [] });
165182
expect(
166183
classifyPullRequestFreshness({ state: "open", head: { sha: "NewSha" } }, "oldsha"),
167184
).toMatchObject({ status: "stale", reason: "head_changed", expectedHeadSha: "oldsha", liveHeadSha: "newsha" });

test/unit/queue.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => {
7272
status: "current" as const,
7373
liveHeadSha: args.expectedHeadSha ?? null,
7474
liveState: "open",
75+
liveLabels: [] as string[],
7576
})),
7677
};
7778
});
@@ -102,6 +103,7 @@ describe("queue processors", () => {
102103
status: "current",
103104
liveHeadSha: args.expectedHeadSha ?? null,
104105
liveState: "open",
106+
liveLabels: [],
105107
}));
106108
vi.useFakeTimers({ toFake: ["Date"] });
107109
vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z"));

test/unit/routes-agent-approval.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => {
1717
status: "current" as const,
1818
liveHeadSha: args.expectedHeadSha ?? null,
1919
liveState: "open",
20+
liveLabels: [] as string[],
2021
})),
2122
};
2223
});

0 commit comments

Comments
 (0)