fix(server): schedule only outstanding merge confirmations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 16:19:37 -05:00
1 parent 0e15c92073
commit e55bc2df65
11 files changed
+474 -65

No files matched your search

@@ -1,3 +1,4 @@
import { notifyMergeConfirmationWork } from "./merged-pr-confirmation-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export type ChatConfirmationScope = { companyId: string; issueId?: string };
@@ -10,6 +11,7 @@ export async function notifyChatConfirmationWork(tx: object, scope: ChatConfirma
/** Interaction resolution can release a callback parked while its human input
* was pending. Register on the owning transaction before its resolution write. */
export async function notifyChatConfirmationResolution(tx: object, interaction: ChatConfirmationScope & { kind: string }) {
if (interaction.kind === "request_confirmation") await notifyMergeConfirmationWork(tx, interaction);
if (["request_confirmation", "request_checkbox_confirmation", "suggest_tasks", "connection_intent"].includes(interaction.kind)) {
await notifyChatConfirmationWork(tx, interaction);
}
@@ -97,6 +97,7 @@ export const DELIVERY_QUEUES = {
browser: "browser-use",
email: "email-channels",
museMaintenance: "muse-maintenance",
mergedPullRequestConfirmations: "merged-pull-request-confirmations",
mcpEvents: "mcp-events",
dotEvents: "dot-events",
grokDispatch: "grok-dispatch",
@@ -1,3 +1,4 @@
import { notifyMergeConfirmationWork, type MergeConfirmationScope } from "./merged-pr-confirmation-notifications.js";
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
import { notifyIssueReviewState } from "./issue-review-state-notifications.js";
import { notifyChatConfirmationResolution } from "./chat-confirmation-notifications.js";
@@ -501,6 +502,7 @@ type IssueResolutionContext = {
companyId: string;
status: string;
executionRunId: string | null;
hiddenAt?: Date | null;
workMode: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
@@ -2074,6 +2076,32 @@ export function issueThreadInteractionService(
);
return states;
}
async function mergedPullRequestConfirmationCandidates(scope?: MergeConfirmationScope) {
const text = sql`concat_ws(' ', ${issueThreadInteractions.title}, ${issueThreadInteractions.summary},
${issueThreadInteractions.payload}->>'prompt', ${issueThreadInteractions.payload}->>'acceptLabel',
${issueThreadInteractions.payload}->>'detailsMarkdown', ${issueThreadInteractions.payload}->'target'->>'key',
${issueThreadInteractions.payload}->'target'->>'label', ${issueThreadInteractions.payload}->'target'->>'href')`;
const rows = await db.select({ interaction: issueThreadInteractions, issue: {
id: issues.id, companyId: issues.companyId, projectId: issues.projectId, goalId: issues.goalId,
status: issues.status, assigneeAgentId: issues.assigneeAgentId,
} }).from(issueThreadInteractions).innerJoin(issues, and(
eq(issueThreadInteractions.issueId, issues.id), eq(issueThreadInteractions.companyId, issues.companyId),
)).where(and(
eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "pending"),
isNull(issues.hiddenAt), sql`${issues.status} NOT IN ('done', 'cancelled')`,
scope ? eq(issueThreadInteractions.companyId, scope.companyId) : undefined,
scope?.issueId ? eq(issueThreadInteractions.issueId, scope.issueId) : undefined,
scope?.interactionId ? eq(issueThreadInteractions.id, scope.interactionId) : undefined,
sql`${text} ~* '(merge|merging)'`,
sql`${text} ~* '(github\\.com/[^ /]+/[^ /]+/pull/[1-9][0-9]*|[a-z0-9_.-]+/[a-z0-9_.-]+#[1-9][0-9]*)'`,
sql`NOT EXISTS (SELECT 1 FROM ${toolActionRequests} WHERE ${toolActionRequests.interactionId} = ${issueThreadInteractions.id})`,
));
return rows.flatMap(row => {
const references = getMergeConfirmationPullRequestReferences(row.interaction);
return references.length ? [{ ...row, references }] : [];
});
}
async function getIdempotentInteraction(args: {
issueId: string;
companyId: string;
@@ -2271,6 +2299,7 @@ export function issueThreadInteractionService(
companyId: issues.companyId,
status: issues.status,
executionRunId: issues.executionRunId,
hiddenAt: issues.hiddenAt,
workMode: issues.workMode,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -2309,6 +2338,10 @@ export function issueThreadInteractionService(
"Interaction has already been resolved",
);
}
if (args.actor.systemId === "system:pr-merged" &&
(issueContext.hiddenAt || getMergeConfirmationPullRequestReferences(lockedCurrent).length === 0)) {
throw conflict("Merge confirmation is no longer actionable");
}
await assertRequestConfirmationResolutionAllowedUnderLock(
tx as unknown as Db,
issueContext,
@@ -2979,6 +3012,7 @@ export function issueThreadInteractionService(
await emitInteractionResolvedTelemetry(db, interaction);
return interaction;
},
mergedPullRequestConfirmationCandidates,
sweepMergedPullRequestConfirmations: async (
mergedHints: Array<{
companyId: string;
@@ -2986,51 +3020,17 @@ export function issueThreadInteractionService(
repo: string;
number: number;
}> = [],
scope?: MergeConfirmationScope,
signal?: AbortSignal,
) => {
const rows = await db
.select({
interaction: issueThreadInteractions,
issue: {
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
goalId: issues.goalId,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
},
})
.from(issueThreadInteractions)
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
.where(
and(
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "pending"),
),
);
const candidates = rows.flatMap((row) => {
const references = getMergeConfirmationPullRequestReferences(
row.interaction,
);
return references.length > 0 ? [{ ...row, references }] : [];
});
if (candidates.length === 0) {
return { checked: rows.length, candidates: 0, accepted: 0, woken: 0 };
}
const candidateIds = candidates.map(({ interaction }) => interaction.id);
const linkedToolActions = await db
.select({ interactionId: toolActionRequests.interactionId })
.from(toolActionRequests)
.where(inArray(toolActionRequests.interactionId, candidateIds));
const toolActionInteractionIds = new Set(
linkedToolActions
.map((row) => row.interactionId)
.filter((value): value is string => Boolean(value)),
);
const eligible = candidates.filter(
({ interaction }) => !toolActionInteractionIds.has(interaction.id),
);
const candidates = await mergedPullRequestConfirmationCandidates(scope ??
(mergedHints.length === 1 ? { companyId: mergedHints[0]!.companyId } : undefined));
const eligible = mergedHints.length ? candidates.filter(candidate => mergedHints.some(hint =>
hint.companyId === candidate.issue.companyId && candidate.references.some(reference =>
hint.owner.toLowerCase() === reference.owner.toLowerCase() &&
hint.repo.toLowerCase() === reference.repo.toLowerCase() && hint.number === reference.number),
)) : candidates;
if (eligible.length === 0) return { checked: 0, candidates: 0, accepted: 0, woken: 0 };
const checkedAt = now().getTime();
const cacheTtlMs = opts.pullRequestCacheTtlMs ?? 5 * 60 * 1000;
@@ -3065,6 +3065,9 @@ export function issueThreadInteractionService(
const refreshedStates = await resolvePullRequestStates([
...uniqueReferences.values(),
]);
if (scope && [...refreshedStates.values()].some(state => state === "unknown")) {
throw new Error("Merge confirmation provider state is unavailable");
}
for (const [key, state] of refreshedStates) {
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, {
state,
@@ -3075,6 +3078,7 @@ export function issueThreadInteractionService(
let accepted = 0;
let woken = 0;
for (const candidate of eligible) {
if (signal?.aborted) break;
const allMerged = candidate.references.every((reference) => {
const key = `${candidate.issue.companyId}:${reference.owner.toLowerCase()}/${reference.repo.toLowerCase()}#${reference.number}`;
return pullRequestStateCache.get(key)?.state === "merged";
@@ -3111,12 +3115,16 @@ export function issueThreadInteractionService(
if (resolved.interaction.status !== "accepted") continue;
accepted += 1;
const wakeIssue = resolved.continuationIssue ?? candidate.issue;
const wakeIssue = resolved.continuationIssue ?? await db.select({
id: issues.id, assigneeAgentId: issues.assigneeAgentId, status: issues.status,
}).from(issues).where(and(eq(issues.id, candidate.issue.id),
eq(issues.companyId, candidate.issue.companyId), isNull(issues.hiddenAt))).then(rows => rows[0]);
const shouldWake =
resolved.interaction.continuationPolicy === "wake_assignee" ||
resolved.interaction.continuationPolicy === "wake_assignee_on_accept";
if (
!opts.wakeup ||
!wakeIssue ||
!shouldWake ||
!wakeIssue.assigneeAgentId ||
isTerminalIssueStatus(wakeIssue.status)
@@ -3158,7 +3166,7 @@ export function issueThreadInteractionService(
}
return {
checked: rows.length,
checked: candidates.length,
candidates: eligible.length,
accepted,
woken,
@@ -3909,6 +3917,7 @@ export function issueThreadInteractionService(
} else {
await notifyNativeStatusReassessment(tx, { companyId: issue.companyId, issueId: issue.id });
}
if (data.kind === "request_confirmation") await notifyMergeConfirmationWork(tx, { companyId: issue.companyId, issueId: issue.id });
const [row] = await tx
.insert(issueThreadInteractions)
.values({
+4
View File
@@ -1,3 +1,4 @@
import { notifyMergeConfirmationWork } from "./merged-pr-confirmation-notifications.js";
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
import { notifyNativeRecoveryNotices } from "./native-recovery-notice-notifications.js";
import { notifyNativeRunProjection } from "./native-run-projection-notifications.js";
@@ -11491,6 +11492,9 @@ export function issueService(db: Db) {
|| issueData.parentId !== undefined || issueData.executionWorkspaceId !== undefined) {
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
}
if (patch.status !== undefined || patch.hiddenAt !== undefined) {
await notifyMergeConfirmationWork(tx, { companyId: receiptExisting.companyId, issueId: id });
}
if (patch.status !== undefined || patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined
|| patch.hiddenAt !== undefined || patch.conversationAgentId !== undefined) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
@@ -0,0 +1,7 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export type MergeConfirmationScope = { companyId: string; issueId?: string; interactionId?: string };
export function notifyMergeConfirmationWork(tx: object, scope: MergeConfirmationScope) {
return notifyDeliveryWork(tx, DELIVERY_QUEUES.mergedPullRequestConfirmations, JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId, interactionId: scope.interactionId }));
}
@@ -0,0 +1,30 @@
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import type { issueThreadInteractionService } from "./issue-thread-interactions.js";
import type { MergeConfirmationScope } from "./merged-pr-confirmation-notifications.js";
import { registerScopedDeadlineWork } from "./scoped-deadline-work.js";
export const MERGE_CONFIRMATION_RECONCILE_MS = 5 * 60_000;
export function registerMergeConfirmationWork(
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
service: Pick<ReturnType<typeof issueThreadInteractionService>,
"mergedPullRequestConfirmationCandidates" | "sweepMergedPullRequestConfirmations">,
canRun: () => boolean,
) {
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.mergedPullRequestConfirmations,
decodeScope: hint => hint === "*" ? undefined : JSON.parse(hint) as MergeConfirmationScope,
matches: (entry, scope?: MergeConfirmationScope) => !scope || (entry.companyId === scope.companyId &&
(!scope.issueId || entry.issueId === scope.issueId) && (!scope.interactionId || entry.id === scope.interactionId)),
load: async (scope?: MergeConfirmationScope) => (await service.mergedPullRequestConfirmationCandidates(scope))
.map(({ interaction }) => ({ id: interaction.id, companyId: interaction.companyId, issueId: interaction.issueId, dueAt: Date.now() })),
check: async (entry, signal) => {
const scope = { companyId: entry.companyId, issueId: entry.issueId, interactionId: entry.id };
await service.sweepMergedPullRequestConfirmations([], scope, signal);
if (signal?.aborted) return entry;
const remaining = await service.mergedPullRequestConfirmationCandidates(scope);
return remaining.length ? { ...entry, dueAt: Date.now() + MERGE_CONFIRMATION_RECONCILE_MS } : null;
},
}, canRun);
}