Skip to content

Commit 67dd74c

Browse files
committed
fix(queue): add a per-PR actuation mutex for the draft-dodge and reopen-reclose paths
Two different webhook deliveries for the same PR (e.g. a reopened event and a concurrent check_suite completed event) could be dequeued by separate workers at nearly the same time. Both would read the same stale-but-still-"current" state, both pass their own freshness checks, and both independently fire a mutating call — a TOCTOU window with no per-PR mutex anywhere in the actuation path. Add a lightweight interim mutex (short-TTL transient-cache claim, best-effort release) and wrap the draft-dodge close and reopen-reclose handlers with it — the two mutating webhook-triggered paths that weren't already covered by an existing per-PR lock. A lock-contended caller fails open (skips this pass); the delivery holding the lock is evaluating the same PR, and the periodic sweep is the backstop if this specific trigger is dropped. Deliberately NOT the queue-level "widen the coalesce lookup to match status='processing'" interim step the issue also floats: that would have enqueue() silently UPDATE a claimed row's payload, which never gets re-read before the claiming worker deletes the row on completion — a coalesce that reports success while permanently discarding the new event's trigger. The per-PR mutex avoids that failure mode entirely. A full per-PR Durable Object (SubmissionLock) remains a separate, larger follow-up per the existing TODO in env.d.ts.
1 parent 22ec2e4 commit 67dd74c

2 files changed

Lines changed: 277 additions & 112 deletions

File tree

src/queue/processors.ts

Lines changed: 219 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -2304,6 +2304,40 @@ async function putTransientKey(
23042304
}
23052305
}
23062306

2307+
// Per-PR actuation mutex (#2135). Two DIFFERENT webhook deliveries for the same PR (e.g. a `reopened` event and
2308+
// a concurrent `check_suite completed` event) can be dequeued by separate workers at nearly the same time; both
2309+
// would read the same stale-but-still-"current" state, both pass their own freshness checks, and both
2310+
// independently fire a mutating call. This is a lightweight interim mutex (a full per-PR Durable Object /
2311+
// SubmissionLock is a separate, more-involved follow-up — see the TODO in env.d.ts) built on the SAME transient
2312+
// cache used for CI-completion coalescing above: a short-TTL claim, best-effort release. A lock-contended caller
2313+
// fails OPEN (returns false / skips this pass) rather than blocking — the delivery holding the lock is
2314+
// evaluating the SAME PR, and the periodic sweep is the backstop if this specific trigger is dropped.
2315+
const PR_ACTUATION_LOCK_TTL_SECONDS = 60;
2316+
function prActuationLockKey(repoFullName: string, prNumber: number): string {
2317+
return `pr-actuation-lock:${repoFullName.toLowerCase()}#${prNumber}`;
2318+
}
2319+
async function claimPrActuationLock(
2320+
env: Env,
2321+
repoFullName: string,
2322+
prNumber: number,
2323+
): Promise<boolean> {
2324+
const key = prActuationLockKey(repoFullName, prNumber);
2325+
if (await getTransientKey(env, key)) return false;
2326+
await putTransientKey(env, key, "1", PR_ACTUATION_LOCK_TTL_SECONDS);
2327+
return true;
2328+
}
2329+
async function releasePrActuationLock(
2330+
env: Env,
2331+
repoFullName: string,
2332+
prNumber: number,
2333+
): Promise<void> {
2334+
try {
2335+
await env.SELFHOST_TRANSIENT_CACHE?.del?.(prActuationLockKey(repoFullName, prNumber));
2336+
} catch {
2337+
// best-effort
2338+
}
2339+
}
2340+
23072341
/**
23082342
* True when CI for this PR+headSha has been pending past STUCK_CI_DEFER_MS. Stamps the first-seen time in a
23092343
* transient cache keyed by repo#pr:headSha — a new push is a new SHA, so the window resets per commit. A missing
@@ -3743,120 +3777,14 @@ async function processGitHubWebhook(
37433777
!settings.agentPaused &&
37443778
!isProtectedAutomationAuthor(pr.authorLogin)
37453779
) {
3746-
const block = await getGateBlockOutcome(
3780+
await maybeCloseDraftDodgeAttempt(
37473781
env,
3782+
deliveryId,
3783+
installationId,
37483784
repoFullName,
3749-
pr.number,
3785+
pr,
3786+
settings,
37503787
).catch(() => undefined);
3751-
const repoOwner = repoFullName.includes("/")
3752-
? repoFullName.slice(0, repoFullName.indexOf("/")).toLowerCase()
3753-
: "";
3754-
const draftDodgeAuthorLogin = (pr.authorLogin ?? "").toLowerCase();
3755-
const authorIsOwner =
3756-
draftDodgeAuthorLogin === repoOwner && repoOwner.length > 0;
3757-
// Fleet-operator identity (#2133): same ADMIN_GITHUB_LOGINS exemption as the primary close-eligibility
3758-
// computation above and hasMaintainerPermission below — an admin login must never be auto-closed here
3759-
// either, matching every other actuation path's trusted-operator definition.
3760-
const authorIsAdmin =
3761-
draftDodgeAuthorLogin.length > 0 &&
3762-
parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(draftDodgeAuthorLogin);
3763-
if (
3764-
block &&
3765-
block.headSha === pr.headSha &&
3766-
!block.overridden &&
3767-
!authorIsOwner &&
3768-
!authorIsAdmin
3769-
) {
3770-
// Respect the agent action mode (#killswitch-gap): the outer guard already excludes a per-repo pause,
3771-
// but this close path must also honor the global freeze and dry-run — so a freeze is a COMPLETE stop
3772-
// and a dry-run records the would-be close without touching GitHub.
3773-
const draftMode = resolveAgentActionMode({
3774-
globalPaused:
3775-
isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)),
3776-
agentPaused: settings.agentPaused,
3777-
agentDryRun: settings.agentDryRun,
3778-
});
3779-
if (draftMode === "live") {
3780-
// Live re-check (#2130): the two async DB reads above (getGateBlockOutcome, resolveAgentActionMode's
3781-
// isGlobalAgentFrozen) leave a window where a maintainer could merge/close the PR, or a fresh push
3782-
// could clear the gate failure, before this fires. Unlike the main gate-close path — which routes
3783-
// every close through executeAgentMaintenanceActions's freshness guard — this handler acted purely
3784-
// off the stale webhook-ingestion payload. Re-verify live state immediately before the mutation.
3785-
// requireDraft: head/state alone would still read "current" if the author converted the PR BACK
3786-
// to ready_for_review in that window -- the draft-dodge close's own justification no longer
3787-
// holds, since there is no longer a draft to be "dodging" the gate through.
3788-
const freshness = await fetchPullRequestFreshness(env, {
3789-
installationId,
3790-
repoFullName,
3791-
pullNumber: pr.number,
3792-
expectedHeadSha: pr.headSha,
3793-
requireDraft: true,
3794-
});
3795-
if (freshness.status !== "current") {
3796-
await recordAuditEvent(env, {
3797-
eventType: "github_app.draft_dodge_closed",
3798-
actor: "gittensory",
3799-
targetKey: `${repoFullName}#${pr.number}`,
3800-
outcome: "denied",
3801-
detail: `${pullRequestFreshnessDetail(freshness)} — draft-dodge close not executed`,
3802-
metadata: {
3803-
deliveryId,
3804-
repoFullName,
3805-
headSha: pr.headSha,
3806-
blockerCodes: block.blockerCodes,
3807-
},
3808-
}).catch(() => undefined);
3809-
} else {
3810-
const codes = block.blockerCodes.join(", ");
3811-
await createIssueComment(
3812-
env,
3813-
installationId,
3814-
repoFullName,
3815-
pr.number,
3816-
`Gate verdict stands for this commit — converting to draft does not reset the review. Re-submit a new PR with the issues addressed${codes ? ` (${codes})` : ""}.`,
3817-
).catch(() => undefined);
3818-
await closePullRequest(
3819-
env,
3820-
installationId,
3821-
repoFullName,
3822-
pr.number,
3823-
).catch(() => undefined);
3824-
await recordAuditEvent(env, {
3825-
eventType: "github_app.draft_dodge_closed",
3826-
actor: "gittensory",
3827-
targetKey: `${repoFullName}#${pr.number}`,
3828-
outcome: "completed",
3829-
detail: `closed draft-dodge attempt by ${pr.authorLogin ?? "unknown"} — prior gate failure on headSha ${pr.headSha} stands`,
3830-
metadata: {
3831-
deliveryId,
3832-
repoFullName,
3833-
headSha: pr.headSha,
3834-
blockerCodes: block.blockerCodes,
3835-
},
3836-
}).catch(() => undefined);
3837-
}
3838-
} else if (draftMode === "dry_run") {
3839-
/* v8 ignore next -- a deleted-account PR yields a null author login; the fallback is defensive */
3840-
const draftAuthor = pr.authorLogin ?? "unknown";
3841-
await recordAuditEvent(env, {
3842-
eventType: "github_app.draft_dodge_closed",
3843-
actor: "gittensory",
3844-
targetKey: `${repoFullName}#${pr.number}`,
3845-
outcome: "completed",
3846-
detail: `dry-run: would close draft-dodge attempt by ${draftAuthor} — prior gate failure on headSha ${pr.headSha} stands`,
3847-
metadata: {
3848-
deliveryId,
3849-
repoFullName,
3850-
headSha: pr.headSha,
3851-
blockerCodes: block.blockerCodes,
3852-
mode: "dry_run",
3853-
},
3854-
}).catch(
3855-
/* v8 ignore next -- fail-safe: an audit write failure never blocks the handler */
3856-
() => undefined,
3857-
);
3858-
}
3859-
}
38603788
}
38613789
if (
38623790
installationId &&
@@ -7715,16 +7643,195 @@ async function recordPrPanelRetriggerSkip(
77157643
});
77167644
}
77177645

7646+
/** Draft-dodge guard (#converted-to-draft): a contributor converting an OPEN PR to draft cannot use draft state
7647+
* to keep a gate-rejected PR alive. When a prior gate failure exists for the PR's current headSha (and the
7648+
* block has not been maintainer-overridden), close the PR immediately — the gate verdict stands and does not
7649+
* reset on draft conversion. Per-PR actuation-locked (#2135): a concurrent delivery for the same PR must not
7650+
* evaluate + potentially mutate it at the same time. Lock-contended is a silent no-op for this pass — the
7651+
* delivery holding the lock is handling this PR. */
7652+
async function maybeCloseDraftDodgeAttempt(
7653+
env: Env,
7654+
deliveryId: string,
7655+
installationId: number,
7656+
repoFullName: string,
7657+
pr: PullRequestRecord,
7658+
settings: RepositorySettings,
7659+
): Promise<void> {
7660+
if (!(await claimPrActuationLock(env, repoFullName, pr.number))) return;
7661+
try {
7662+
await closeDraftDodgeAttemptIfBlocked(
7663+
env,
7664+
deliveryId,
7665+
installationId,
7666+
repoFullName,
7667+
pr,
7668+
settings,
7669+
);
7670+
} finally {
7671+
await releasePrActuationLock(env, repoFullName, pr.number);
7672+
}
7673+
}
7674+
7675+
async function closeDraftDodgeAttemptIfBlocked(
7676+
env: Env,
7677+
deliveryId: string,
7678+
installationId: number,
7679+
repoFullName: string,
7680+
pr: PullRequestRecord,
7681+
settings: RepositorySettings,
7682+
): Promise<void> {
7683+
const block = await getGateBlockOutcome(
7684+
env,
7685+
repoFullName,
7686+
pr.number,
7687+
).catch(() => undefined);
7688+
const repoOwner = repoFullName.includes("/")
7689+
? repoFullName.slice(0, repoFullName.indexOf("/")).toLowerCase()
7690+
: "";
7691+
const draftDodgeAuthorLogin = (pr.authorLogin ?? "").toLowerCase();
7692+
const authorIsOwner =
7693+
draftDodgeAuthorLogin === repoOwner && repoOwner.length > 0;
7694+
// Fleet-operator identity (#2133): same ADMIN_GITHUB_LOGINS exemption as the primary close-eligibility
7695+
// computation elsewhere — an admin login must never be auto-closed here either, matching every other
7696+
// actuation path's trusted-operator definition.
7697+
const authorIsAdmin =
7698+
draftDodgeAuthorLogin.length > 0 &&
7699+
parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(draftDodgeAuthorLogin);
7700+
if (
7701+
block &&
7702+
block.headSha === pr.headSha &&
7703+
!block.overridden &&
7704+
!authorIsOwner &&
7705+
!authorIsAdmin
7706+
) {
7707+
// Respect the agent action mode (#killswitch-gap): the outer guard already excludes a per-repo pause,
7708+
// but this close path must also honor the global freeze and dry-run — so a freeze is a COMPLETE stop
7709+
// and a dry-run records the would-be close without touching GitHub.
7710+
const draftMode = resolveAgentActionMode({
7711+
globalPaused:
7712+
isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)),
7713+
agentPaused: settings.agentPaused,
7714+
agentDryRun: settings.agentDryRun,
7715+
});
7716+
if (draftMode === "live") {
7717+
// Live re-check (#2130): the two async DB reads above (getGateBlockOutcome, resolveAgentActionMode's
7718+
// isGlobalAgentFrozen) leave a window where a maintainer could merge/close the PR, or a fresh push
7719+
// could clear the gate failure, before this fires. Unlike the main gate-close path — which routes
7720+
// every close through executeAgentMaintenanceActions's freshness guard — this handler acted purely
7721+
// off the stale webhook-ingestion payload. Re-verify live state immediately before the mutation.
7722+
// requireDraft: head/state alone would still read "current" if the author converted the PR BACK
7723+
// to ready_for_review in that window -- the draft-dodge close's own justification no longer
7724+
// holds, since there is no longer a draft to be "dodging" the gate through.
7725+
const freshness = await fetchPullRequestFreshness(env, {
7726+
installationId,
7727+
repoFullName,
7728+
pullNumber: pr.number,
7729+
expectedHeadSha: pr.headSha,
7730+
requireDraft: true,
7731+
});
7732+
if (freshness.status !== "current") {
7733+
await recordAuditEvent(env, {
7734+
eventType: "github_app.draft_dodge_closed",
7735+
actor: "gittensory",
7736+
targetKey: `${repoFullName}#${pr.number}`,
7737+
outcome: "denied",
7738+
detail: `${pullRequestFreshnessDetail(freshness)} — draft-dodge close not executed`,
7739+
metadata: {
7740+
deliveryId,
7741+
repoFullName,
7742+
headSha: pr.headSha,
7743+
blockerCodes: block.blockerCodes,
7744+
},
7745+
}).catch(() => undefined);
7746+
} else {
7747+
const codes = block.blockerCodes.join(", ");
7748+
await createIssueComment(
7749+
env,
7750+
installationId,
7751+
repoFullName,
7752+
pr.number,
7753+
`Gate verdict stands for this commit — converting to draft does not reset the review. Re-submit a new PR with the issues addressed${codes ? ` (${codes})` : ""}.`,
7754+
).catch(() => undefined);
7755+
await closePullRequest(
7756+
env,
7757+
installationId,
7758+
repoFullName,
7759+
pr.number,
7760+
).catch(() => undefined);
7761+
await recordAuditEvent(env, {
7762+
eventType: "github_app.draft_dodge_closed",
7763+
actor: "gittensory",
7764+
targetKey: `${repoFullName}#${pr.number}`,
7765+
outcome: "completed",
7766+
detail: `closed draft-dodge attempt by ${pr.authorLogin ?? "unknown"} — prior gate failure on headSha ${pr.headSha} stands`,
7767+
metadata: {
7768+
deliveryId,
7769+
repoFullName,
7770+
headSha: pr.headSha,
7771+
blockerCodes: block.blockerCodes,
7772+
},
7773+
}).catch(() => undefined);
7774+
}
7775+
} else if (draftMode === "dry_run") {
7776+
/* v8 ignore next -- a deleted-account PR yields a null author login; the fallback is defensive */
7777+
const draftAuthor = pr.authorLogin ?? "unknown";
7778+
await recordAuditEvent(env, {
7779+
eventType: "github_app.draft_dodge_closed",
7780+
actor: "gittensory",
7781+
targetKey: `${repoFullName}#${pr.number}`,
7782+
outcome: "completed",
7783+
detail: `dry-run: would close draft-dodge attempt by ${draftAuthor} — prior gate failure on headSha ${pr.headSha} stands`,
7784+
metadata: {
7785+
deliveryId,
7786+
repoFullName,
7787+
headSha: pr.headSha,
7788+
blockerCodes: block.blockerCodes,
7789+
mode: "dry_run",
7790+
},
7791+
}).catch(
7792+
/* v8 ignore next -- fail-safe: an audit write failure never blocks the handler */
7793+
() => undefined,
7794+
);
7795+
}
7796+
}
7797+
}
7798+
77187799
/** Reopen-prevention (#one-shot-reopen): re-close a contributor's reopen of a PR that gittensory / a maintainer
77197800
* closed (closes are one-shot). Returns true when it re-closed (caller skips the re-review). Exempt: the bot's
7720-
* own re-review reopens, owner/admin reopens, and a contributor reopening a PR they CLOSED THEMSELVES. */
7801+
* own re-review reopens, owner/admin reopens, and a contributor reopening a PR they CLOSED THEMSELVES.
7802+
* Per-PR actuation-locked (#2135): a concurrent delivery for the same PR (e.g. a check_suite completion racing
7803+
* this reopen) must not evaluate + potentially mutate this PR at the same time. Lock-contended fails open
7804+
* (returns false, falls through to normal re-review) — the delivery holding the lock is handling this PR. */
77217805
async function maybeRecloseDisallowedReopen(
77227806
env: Env,
77237807
deliveryId: string,
77247808
installationId: number,
77257809
repoFullName: string,
77267810
pr: PullRequestRecord,
77277811
payload: GitHubWebhookPayload,
7812+
): Promise<boolean> {
7813+
if (!(await claimPrActuationLock(env, repoFullName, pr.number))) return false;
7814+
try {
7815+
return await recloseDisallowedReopenIfNeeded(
7816+
env,
7817+
deliveryId,
7818+
installationId,
7819+
repoFullName,
7820+
pr,
7821+
payload,
7822+
);
7823+
} finally {
7824+
await releasePrActuationLock(env, repoFullName, pr.number);
7825+
}
7826+
}
7827+
7828+
async function recloseDisallowedReopenIfNeeded(
7829+
env: Env,
7830+
deliveryId: string,
7831+
installationId: number,
7832+
repoFullName: string,
7833+
pr: PullRequestRecord,
7834+
payload: GitHubWebhookPayload,
77287835
): Promise<boolean> {
77297836
const reopener = (payload.sender?.login ?? "").toLowerCase();
77307837
if (!reopener) return false;

0 commit comments

Comments
 (0)