From c223cd72c10b8523e68e93427d2e8620a5b2830a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 05:17:26 -0700 Subject: [PATCH 1/5] fix(agent-actions): re-verify live CI before a merge or heuristic close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freshness guard in executeAgentMaintenanceActions re-checks head SHA and PR state before every live action, but not CI. The CI aggregate that drove a heuristic close or a merge decision is read once in the planning pass, seconds-to-tens-of-seconds before actuation, and never re-read at the moment of mutation. GitHub's merge endpoint enforces branch-protection required checks server-side only as a backstop when a repo configures them, and a heuristic close has no server-side check at all — unlike the deterministic linked-issue-hard-rule close, a heuristic CI-driven close has no flag-then-verify pass either. Add a new guard step that re-derives live CI via the existing fetchLiveCiAggregate helper immediately before a merge or a heuristic close (closeKind: "heuristic"), and denies the action if a merge's CI has since turned failed, or a close's CI is no longer failed. Deterministic closes (linked-issue hard-rule, blacklist) are exempt. Best-effort: a token-mint failure fails open, since this is a defense-in-depth check, not the primary gate (the freshness check above it already fails closed on an unverifiable PR state). --- src/services/agent-action-executor.ts | 32 +++++++++++++- test/unit/agent-action-executor.test.ts | 56 +++++++++++++++++++++++++ test/unit/agent-approval-queue.test.ts | 10 +++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 2e2bde549a..0bad430a76 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -1,6 +1,9 @@ import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, isGlobalAgentFrozen, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories"; import { classifyMergeFailure, MERGE_RETRY_CAP } from "./merge-failure"; import { notifyActionToDiscord, notifyActionToSlack, type NotifyOutcome } from "./notify-discord"; +import { createInstallationToken } from "../github/app"; +import { fetchLiveCiAggregate } from "../github/backfill"; +import { githubRateLimitAdmissionKeyForToken } from "../github/client"; import { ensurePullRequestLabel, removePullRequestLabel } from "../github/labels"; import { closePullRequest, createIssueComment, createPullRequestReview, mergePullRequest, updatePullRequestBranch } from "../github/pr-actions"; import { fetchPullRequestFreshness, pullRequestFreshnessDetail } from "../github/pr-freshness"; @@ -114,12 +117,37 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE await audit("denied", `${pullRequestFreshnessDetail(freshness)} — action not executed`); continue; } - // 6) Write-permission readiness: a PR-write action needs `pull_requests: write` granted. + // 6) Live CI re-verification for a merge or a heuristic close (#2128): the CI aggregate that drove either + // decision was read seconds-to-tens-of-seconds earlier, in the planning pass, and the freshness guard + // above only re-checks head SHA/state, not CI. GitHub's own merge endpoint enforces branch-protection + // REQUIRED checks server-side, but only as a backstop when a repo actually configures them; a heuristic + // close has no server-side check at all. Re-read live CI right before the mutation so a check that + // flipped in this narrow window is never acted on from stale information. Deterministic closes + // (linked-issue hard-rule, blacklist) are exempt — they are zero-hallucination facts that do not depend + // on CI, and the linked-issue rule already has its own flag-then-verify pass. + if (action.actionClass === "merge" || (action.actionClass === "close" && action.closeKind === "heuristic")) { + const ciToken = await createInstallationToken(env, ctx.installationId).catch(() => undefined); + const admissionKey = githubRateLimitAdmissionKeyForToken(env, ciToken, ctx.installationId); + const liveCi = await fetchLiveCiAggregate(env, ctx.repoFullName, expectedHeadSha, ciToken, undefined, admissionKey); + const staleReason = + action.actionClass === "merge" + ? liveCi.ciState === "failed" + ? "live CI is now failing" + : null + : liveCi.ciState !== "failed" + ? `CI state changed since planning (now: ${liveCi.ciState})` + : null; + if (staleReason) { + await audit("denied", `${staleReason} — action not executed`); + continue; + } + } + // 7) Write-permission readiness: a PR-write action needs `pull_requests: write` granted. if (PR_WRITE_CLASSES.has(action.actionClass) && resolveAgentPermissionReadiness({ autonomy: ctx.autonomy, installationPermissions: ctx.installationPermissions }) !== "ready") { await audit("denied", "pull_requests: write not granted — maintainer must re-consent"); continue; } - // 7) live — perform the real mutation, recording success or the error. + // 8) live — perform the real mutation, recording success or the error. try { await performAction(env, ctx, action); await audit("completed", action.reason); diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index b6a904f998..cc45086154 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -22,10 +22,22 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => { })), }; }); +vi.mock("../../src/github/app", async (importOriginal) => ({ + ...(await importOriginal()), + createInstallationToken: vi.fn(async () => "test-installation-token"), +})); +// The actuation-time live CI re-check (#2128) defaults to "still passing" so the existing merge tests stay +// deterministic; individual tests below override this to exercise the staleness-denial path. +vi.mock("../../src/github/backfill", async (importOriginal) => ({ + ...(await importOriginal()), + fetchLiveCiAggregate: vi.fn(async () => ({ ciState: "passed" as const, hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] })), +})); import { closePullRequest, createIssueComment, createPullRequestReview, mergePullRequest, updatePullRequestBranch } from "../../src/github/pr-actions"; import { ensurePullRequestLabel, removePullRequestLabel } from "../../src/github/labels"; import { fetchPullRequestFreshness } from "../../src/github/pr-freshness"; +import { createInstallationToken } from "../../src/github/app"; +import { fetchLiveCiAggregate } from "../../src/github/backfill"; import { actionParams, executeAgentMaintenanceActions, pendingClosureLabelApplied, type AgentActionExecutionContext, type AgentActionOutcome } from "../../src/services/agent-action-executor"; import type { PlannedAgentAction } from "../../src/settings/agent-actions"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; @@ -98,6 +110,50 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { expect(fetchPullRequestFreshness).toHaveBeenCalledWith(env, expect.objectContaining({ expectedHeadSha: "reviewed-sha" })); }); + it("LIVE heuristic close is denied when live CI has since turned green (#2128)", async () => { + const env = createTestEnv({}); + const heuristicClose: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "CI failed", closeComment: "closing", closeKind: "heuristic" }; + vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "passed", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [heuristicClose]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(outcomes[0]?.detail).toContain("CI state changed since planning (now: passed)"); + expect(closePullRequest).not.toHaveBeenCalled(); + }); + + it("LIVE heuristic close proceeds when live CI is still failing (#2128)", async () => { + const env = createTestEnv({}); + const heuristicClose: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "CI failed", closeComment: "closing", closeKind: "heuristic" }; + vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "failed", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [heuristicClose]); + expect(outcomes[0]?.outcome).toBe("completed"); + expect(closePullRequest).toHaveBeenCalledWith(env, 123, "owner/repo", 7); + }); + + it("LIVE non-heuristic close (linked-issue hard-rule) skips the live CI re-check entirely (#2128)", async () => { + const env = createTestEnv({}); + const hardRuleClose: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "unlinked issue", closeComment: "closing", closeKind: "linked-issue-hard-rule" }; + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [hardRuleClose]); + expect(outcomes[0]?.outcome).toBe("completed"); + expect(fetchLiveCiAggregate).not.toHaveBeenCalled(); + }); + + it("LIVE merge is denied when live CI has since turned failing (#2128)", async () => { + const env = createTestEnv({}); + vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "failed", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(outcomes[0]?.detail).toContain("live CI is now failing"); + expect(mergePullRequest).not.toHaveBeenCalled(); + }); + + it("the live CI re-check fails open on a token-mint error — it is defense-in-depth, not the primary gate (#2128)", async () => { + const env = createTestEnv({}); + vi.mocked(createInstallationToken).mockRejectedValueOnce(new Error("mint failed")); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]); + expect(outcomes[0]?.outcome).toBe("completed"); + expect(mergePullRequest).toHaveBeenCalledWith(env, 123, "owner/repo", 7, { mergeMethod: "squash", sha: "sha7" }); + }); + it("LIVE label with labelOp=add + comment: adds the label AND posts the comment", async () => { const env = createTestEnv({}); const flag: PlannedAgentAction = { actionClass: "label", requiresApproval: false, reason: "flag", label: "gittensory:pending-closure", labelOp: "add", comment: "⚠️ flagged" }; diff --git a/test/unit/agent-approval-queue.test.ts b/test/unit/agent-approval-queue.test.ts index 49965109fb..b76f527b6a 100644 --- a/test/unit/agent-approval-queue.test.ts +++ b/test/unit/agent-approval-queue.test.ts @@ -20,6 +20,16 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => { })), }; }); +vi.mock("../../src/github/app", async (importOriginal) => ({ + ...(await importOriginal()), + createInstallationToken: vi.fn(async () => "test-installation-token"), +})); +// The actuation-time live CI re-check (#2128) defaults to "still passing" so the existing accept tests stay +// deterministic. +vi.mock("../../src/github/backfill", async (importOriginal) => ({ + ...(await importOriginal()), + fetchLiveCiAggregate: vi.fn(async () => ({ ciState: "passed" as const, hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] })), +})); import { mergePullRequest } from "../../src/github/pr-actions"; import { ensurePullRequestLabel } from "../../src/github/labels"; From 89ffe2644c849f9ed5d1485ebcb9702deeb99d49 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:21:45 -0700 Subject: [PATCH 2/5] fix(agent-actions): require live CI to still be passing, not merely non-failed The planner itself only ever stages a merge when ciState === "passed" exactly (reviewGood in agent-actions.ts; "pending" short-circuits to no actions at all upstream). The live re-check before actuation only denied on ciState === "failed", so a check that regressed to pending or became unreadable (unverified) between planning and actuation still let the merge proceed on stale information -- exactly the class of gap this guard exists to close. Require the same exact "passed" state the planner itself requires. Also fixes test/unit/routes-agent-approval.test.ts, which pre-dates this PR's live-CI-recheck step entirely and never mocked fetchLiveCiAggregate -- its accept-merge happy path was incidentally passing only because the un-mocked call fell through to "unverified", which the too-lenient original check treated as fine. Mock it to "passed" like the executor's own test file already does. --- src/services/agent-action-executor.ts | 9 +++++++-- test/unit/agent-action-executor.test.ts | 20 +++++++++++++++++++- test/unit/routes-agent-approval.test.ts | 10 ++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 0bad430a76..51a5905739 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -129,10 +129,15 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE const ciToken = await createInstallationToken(env, ctx.installationId).catch(() => undefined); const admissionKey = githubRateLimitAdmissionKeyForToken(env, ciToken, ctx.installationId); const liveCi = await fetchLiveCiAggregate(env, ctx.repoFullName, expectedHeadSha, ciToken, undefined, admissionKey); + // The planner itself only ever stages a merge when ciState === "passed" exactly (reviewGood in + // agent-actions.ts; "pending" short-circuits to no actions at all upstream) -- the live re-check must + // require the SAME exact state, not just "not failed". Otherwise a check that regressed to pending or + // became unreadable (unverified) between planning and actuation would still merge, on the assumption + // that only an explicit failure invalidates the plan. const staleReason = action.actionClass === "merge" - ? liveCi.ciState === "failed" - ? "live CI is now failing" + ? liveCi.ciState !== "passed" + ? `live CI is no longer passing (now: ${liveCi.ciState})` : null : liveCi.ciState !== "failed" ? `CI state changed since planning (now: ${liveCi.ciState})` diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index cc45086154..edb426c8e9 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -142,7 +142,25 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "failed", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }); const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]); expect(outcomes[0]?.outcome).toBe("denied"); - expect(outcomes[0]?.detail).toContain("live CI is now failing"); + expect(outcomes[0]?.detail).toContain("live CI is no longer passing (now: failed)"); + expect(mergePullRequest).not.toHaveBeenCalled(); + }); + + it("REGRESSION (#2364): LIVE merge is denied when live CI has since become pending, not just failed", async () => { + const env = createTestEnv({}); + vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "pending", hasPending: true, hasVisiblePending: true, failingDetails: [], nonRequiredFailingDetails: [] }); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(outcomes[0]?.detail).toContain("live CI is no longer passing (now: pending)"); + expect(mergePullRequest).not.toHaveBeenCalled(); + }); + + it("REGRESSION (#2364): LIVE merge is denied when live CI has since become unverified (unreadable), not just failed", async () => { + const env = createTestEnv({}); + vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "unverified", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(outcomes[0]?.detail).toContain("live CI is no longer passing (now: unverified)"); expect(mergePullRequest).not.toHaveBeenCalled(); }); diff --git a/test/unit/routes-agent-approval.test.ts b/test/unit/routes-agent-approval.test.ts index b28f4df600..58727f2557 100644 --- a/test/unit/routes-agent-approval.test.ts +++ b/test/unit/routes-agent-approval.test.ts @@ -20,6 +20,16 @@ vi.mock("../../src/github/pr-freshness", async (importOriginal) => { })), }; }); +// #2364's live CI re-check (in executeAgentMaintenanceActions) runs for every merge/heuristic-close accept. +// Default to a green re-check so the existing "accept executes the staged merge" happy path still executes +// live instead of being (correctly) denied for CI that this test was never simulating. +vi.mock("../../src/github/backfill", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchLiveCiAggregate: vi.fn(async () => ({ ciState: "passed" as const, hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] })), + }; +}); import { mergePullRequest } from "../../src/github/pr-actions"; import { createSessionForGitHubUser } from "../../src/auth/security"; From c2625f91922abfa6e8c9a5c152c31108860bdef6 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:08:41 -0700 Subject: [PATCH 3/5] fix(agent-actions): persist closeKind so a queued heuristic close's live-CI recheck survives replay actionParams() didn't round-trip closeKind, so pendingActionToPlanned() rebuilt an accepted approval-queue close with closeKind undefined -- silently skipping the actuation-time live CI re-check this PR adds, since that check keys on action.closeKind === "heuristic". --- src/services/agent-action-executor.ts | 1 + src/types.ts | 4 ++++ test/unit/agent-action-executor.test.ts | 18 +++++++++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 51a5905739..d3e7cc9a22 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -266,6 +266,7 @@ export function actionParams(action: PlannedAgentAction): AgentPendingActionPara ...(action.reviewBody !== undefined ? { reviewBody: action.reviewBody } : {}), ...(action.mergeMethod !== undefined ? { mergeMethod: action.mergeMethod } : {}), ...(action.closeComment !== undefined ? { closeComment: action.closeComment } : {}), + ...(action.closeKind !== undefined ? { closeKind: action.closeKind } : {}), ...(action.expectedHeadSha !== undefined ? { expectedHeadSha: action.expectedHeadSha } : {}), }; } diff --git a/src/types.ts b/src/types.ts index fee2d4e0b8..8abe37f269 100644 --- a/src/types.ts +++ b/src/types.ts @@ -682,6 +682,10 @@ export type AgentPendingActionParams = { reviewBody?: string; mergeMethod?: AutoMergeMethod; closeComment?: string; + // Which kind of close this is (linked-issue-hard-rule / blacklist / heuristic), persisted so a queued close's + // actuation-time live-CI re-check (#2364) — which only applies to a heuristic close — still fires correctly + // once the row is replayed through pendingActionToPlanned, not silently skipped for a lost discriminator. + closeKind?: "linked-issue-hard-rule" | "blacklist" | "heuristic"; expectedHeadSha?: string; }; diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index edb426c8e9..cf7c9a614a 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -38,7 +38,7 @@ import { ensurePullRequestLabel, removePullRequestLabel } from "../../src/github import { fetchPullRequestFreshness } from "../../src/github/pr-freshness"; import { createInstallationToken } from "../../src/github/app"; import { fetchLiveCiAggregate } from "../../src/github/backfill"; -import { actionParams, executeAgentMaintenanceActions, pendingClosureLabelApplied, type AgentActionExecutionContext, type AgentActionOutcome } from "../../src/services/agent-action-executor"; +import { actionParams, executeAgentMaintenanceActions, pendingActionToPlanned, pendingClosureLabelApplied, type AgentActionExecutionContext, type AgentActionOutcome } from "../../src/services/agent-action-executor"; import type { PlannedAgentAction } from "../../src/settings/agent-actions"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; import { isGlobalAgentFrozen, setGlobalAgentFrozen } from "../../src/db/repositories"; @@ -129,6 +129,22 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { expect(closePullRequest).toHaveBeenCalledWith(env, 123, "owner/repo", 7); }); + it("REGRESSION (#2364): a queued heuristic close still re-checks live CI after the approval-queue replay round trip", async () => { + const env = createTestEnv({}); + const heuristicClose: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "CI failed", closeComment: "closing", closeKind: "heuristic" }; + // Simulate the persist/replay path: stageForApproval calls actionParams() to persist the row, and accept + // rebuilds it via pendingActionToPlanned(). Without persisting closeKind, the rebuilt action would lose the + // discriminator the live-CI re-check keys on, silently skipping it for every accepted queued heuristic close. + const persisted = actionParams(heuristicClose); + const replayed = pendingActionToPlanned({ actionClass: "close", params: persisted, reason: heuristicClose.reason }); + expect(replayed.closeKind).toBe("heuristic"); + vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "passed", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [replayed]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(outcomes[0]?.detail).toContain("CI state changed since planning (now: passed)"); + expect(closePullRequest).not.toHaveBeenCalled(); + }); + it("LIVE non-heuristic close (linked-issue hard-rule) skips the live CI re-check entirely (#2128)", async () => { const env = createTestEnv({}); const hardRuleClose: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "unlinked issue", closeComment: "closing", closeKind: "linked-issue-hard-rule" }; From 02c887e68d0fae5a9ff74089470b7e462520e903 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:33:48 -0700 Subject: [PATCH 4/5] fix(types): deduplicate the closeKind field on AgentPendingActionParams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merge landed the same closeKind field twice in the same type literal — once from #2127 (close-precision circuit-breaker scoping) and once from this PR's own #2364 (actuation-time live-CI re-check) — causing TS2300 "Duplicate identifier 'closeKind'" and failing validate-code. Collapse to a single declaration with a merged comment explaining both consumers. --- src/types.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/types.ts b/src/types.ts index c18aa2c5e5..890d950607 100644 --- a/src/types.ts +++ b/src/types.ts @@ -682,18 +682,17 @@ export type AgentPendingActionParams = { reviewBody?: string; mergeMethod?: AutoMergeMethod; closeComment?: string; - // Which kind of close this is (linked-issue-hard-rule / blacklist / heuristic), persisted so a queued close's - // actuation-time live-CI re-check (#2364) — which only applies to a heuristic close — still fires correctly - // once the row is replayed through pendingActionToPlanned, not silently skipped for a lost discriminator. + // Which kind of close this is (see PlannedAgentAction.closeKind), persisted so it round-trips through staging: + // the close-precision circuit-breaker still scopes itself correctly when a staged close is later accepted + // (#2127), and the actuation-time live-CI re-check (#2364) — which only applies to a heuristic close — still + // fires correctly once the row is replayed through pendingActionToPlanned, rather than silently skipping for + // a lost discriminator. closeKind?: "linked-issue-hard-rule" | "blacklist" | "heuristic"; expectedHeadSha?: string; // For an `approve` action: retract the bot's own stale approval instead of posting a new one (see // PlannedAgentAction.dismissStaleApproval). Must round-trip through staging like every other action-specific // field. (#2254) dismissStaleApproval?: boolean; - // WHICH kind of close this is (see PlannedAgentAction.closeKind) — must round-trip through staging so the - // close-precision circuit-breaker can still scope itself correctly when a staged close is later accepted (#2127). - closeKind?: "linked-issue-hard-rule" | "blacklist" | "heuristic"; }; export type AgentPendingActionStatus = "pending" | "accepted" | "rejected"; From 7e1e1e8386963aa5208934c519ff7717e998eac5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:37:00 -0700 Subject: [PATCH 5/5] fix(agent-actions): remove a duplicated closeKind spread in actionParams Same merge-conflict-resolution artifact as the AgentPendingActionParams type duplicate: closeKind was spread into the persisted params twice (once bare, once with the #2127 explanatory comment). Both computed the identical value, so this was harmless at runtime, but redundant and confusing. Keep the single, commented copy and note the #2364 live-CI re-check's dependency on it too. --- src/services/agent-action-executor.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index f0406f4d0c..b33abdd18b 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -270,12 +270,13 @@ export function actionParams(action: PlannedAgentAction): AgentPendingActionPara ...(action.reviewBody !== undefined ? { reviewBody: action.reviewBody } : {}), ...(action.mergeMethod !== undefined ? { mergeMethod: action.mergeMethod } : {}), ...(action.closeComment !== undefined ? { closeComment: action.closeComment } : {}), - ...(action.closeKind !== undefined ? { closeKind: action.closeKind } : {}), ...(action.expectedHeadSha !== undefined ? { expectedHeadSha: action.expectedHeadSha } : {}), ...(action.dismissStaleApproval !== undefined ? { dismissStaleApproval: action.dismissStaleApproval } : {}), // Round-trip closeKind so a staged close's kind survives to accept-time — without it, the close-precision // breaker's isHeuristicClose check (which matches on closeKind === "heuristic") could never fire for any - // staged close, silently defeating the breaker for the entire approval-queue accept path (#2127). + // staged close, silently defeating the breaker for the entire approval-queue accept path (#2127), and the + // actuation-time live-CI re-check above (#2364) — which only applies to a heuristic close — would be + // silently skipped for a lost discriminator. ...(action.closeKind !== undefined ? { closeKind: action.closeKind } : {}), }; }