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 { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { inArray } from "drizzle-orm";
@@ -131,7 +132,8 @@ describe("merged pull-request confirmation extraction", () => {
});
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_MERGE_SWEEP_TEST_DATABASE_URL");
const embeddedPostgresSupport = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("merged pull-request confirmation sweep", () => {
@@ -139,8 +141,8 @@ describeEmbeddedPostgres("merged pull-request confirmation sweep", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-merged-pr-confirmations-");
db = createDb(tempDb.connectionString);
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-merged-pr-confirmations-");
db = createDb(external ?? tempDb!.connectionString, { maxConnections: 6 });
}, 20_000);
afterEach(async () => {
@@ -153,6 +155,7 @@ describeEmbeddedPostgres("merged pull-request confirmation sweep", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
@@ -276,7 +279,7 @@ describeEmbeddedPostgres("merged pull-request confirmation sweep", () => {
repo: "paperclip",
number: 39,
}])).resolves.toEqual({
checked: 6,
checked: 3,
candidates: 3,
accepted: 2,
woken: 2,
@@ -0,0 +1,278 @@
import { randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { eq, sql } from "drizzle-orm";
import { activityLog, agents, chatActions, chatConversations, chatEndpoints, chatPublications, companies, createDb, issues, issueThreadInteractions, toolApplications, toolConnections, type Db } from "@paperclipai/db";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { issueService } from "../services/issues.js";
import { registerMergeConfirmationWork, MERGE_CONFIRMATION_RECONCILE_MS } from "../services/merged-pr-confirmation-work.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_MERGE_WORK_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("merge confirmation work with PostgreSQL and simulated GitHub", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
let companyId: string, issueId: string, agentId: string;
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let draining = false, standby = false;
const errors: unknown[] = [];
const provider = vi.fn(async (_companyId: string, _reference: { number: number }): Promise<"open" | "merged" | "unknown"> => "merged");
const wakeup = vi.fn(async () => ({ id: "simulated-durable-wake" }));
beforeAll(async () => {
if (!external) temporary = await startEmbeddedPostgresTestDatabase("merged-pr-work-");
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
}, 30_000);
beforeEach(async () => {
errors.length = 0; draining = false; standby = false;
provider.mockReset().mockResolvedValue("merged"); wakeup.mockClear();
companyId = randomUUID(); agentId = randomUUID(); issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Merge worker", issuePrefix: `M${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Coder", role: "engineer", status: "idle", adapterType: "codex_local" });
await db.insert(issues).values({ id: issueId, companyId, title: "Merge task", status: "todo", assigneeAgentId: agentId });
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: false });
});
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
await db.delete(chatActions); await db.delete(chatPublications); await db.delete(chatConversations); await db.delete(chatEndpoints);
await db.delete(toolConnections); await db.delete(toolApplications);
await db.delete(activityLog); await db.delete(issueThreadInteractions); await db.delete(issues);
await db.delete(agents); await db.delete(companies);
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
const service = (database: Db = db) => issueThreadInteractionService(database, { resolvePullRequestState: provider, wakeup });
const input = (number = 39) => ({ kind: "request_confirmation" as const, title: `Merge paperclipai/paperclip#${number}?`,
resolverPolicy: "human_only" as const, continuationPolicy: "wake_assignee_on_accept" as const,
payload: { version: 1 as const, prompt: "Merge the PR?" } });
const create = (database: Db = db, number = 39) => service(database).create({ id: issueId, companyId }, input(number), { agentId });
const stored = (id: string) => db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, id)).then(rows => rows[0]);
async function start() {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining && !standby,
canReconcile: () => !standby, onError: error => errors.push(error) });
const worker = registerMergeConfirmationWork(coordinator, service(), () => !draining && !standby);
await worker.ready;
return worker;
}
async function waitFor(check: () => Promise<void> | void) {
for (let attempt = 0; attempt < 100; attempt++) {
await vi.advanceTimersByTimeAsync(1000);
await delay(10);
try { await check(); return; } catch (error) { if (attempt === 99) throw error; }
}
}
const settle = () => waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
async function quiet() {
await waitFor(() => { expect(idleWorkSnapshot().active).toBe(0); expect(coordinator!.nextWakeAt()).toBeNull(); });
const reads = vi.spyOn(db, "select"), transactions = vi.spyOn(db, "transaction"), executes = vi.spyOn(db, "execute");
const calls = provider.mock.calls.length;
await vi.advanceTimersByTimeAsync(3 * MERGE_CONFIRMATION_RECONCILE_MS);
expect(reads).not.toHaveBeenCalled(); expect(transactions).not.toHaveBeenCalled(); expect(executes).not.toHaveBeenCalled();
expect(provider).toHaveBeenCalledTimes(calls);
reads.mockRestore(); transactions.mockRestore(); executes.mockRestore();
}
it("settles empty and configured idle, then ordinary creation accepts, audits, wakes once and becomes quiet with retained history", async () => {
await start(); await quiet(); expect(provider).not.toHaveBeenCalled();
const card = await create();
await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted"));
await quiet();
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({ idempotencyKey: `interaction:${card.id}:accepted`, allowRunCoalescing: false }));
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([expect.objectContaining({
action: "issue.thread_interaction_accepted", actorId: "system:pr-merged", details: expect.objectContaining({ interactionId: card.id }),
})]));
expect(errors).toEqual([]);
});
it("reconciles an open candidate at its deadline, never early, then stops after merge", async () => {
provider.mockResolvedValue("open"); await start(); const card = await create();
await waitFor(() => expect(provider).toHaveBeenCalledTimes(1)); await settle();
const dueAt = coordinator!.nextWakeAt()!;
expect(dueAt).toBeGreaterThan(Date.now());
await vi.advanceTimersByTimeAsync(dueAt - Date.now() - 1); expect(provider).toHaveBeenCalledTimes(1);
provider.mockResolvedValue("merged"); await vi.advanceTimersByTimeAsync(1);
await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
expect(provider).toHaveBeenCalledTimes(2); expect(wakeup).toHaveBeenCalledTimes(1);
});
it("manual cancellation removes the candidate deadline without provider work", async () => {
provider.mockResolvedValue("open"); await start(); const card = await create();
await waitFor(() => expect(provider).toHaveBeenCalledTimes(1)); await settle();
await service().withdrawInteraction({ id: issueId, companyId }, card.id, {}, { agentId });
await quiet(); expect(provider).toHaveBeenCalledTimes(1); expect(wakeup).not.toHaveBeenCalled();
expect((await stored(card.id)).status).toBe("cancelled");
});
it("does not accept before caller-owned outer commit and forgets a rolled-back create", async () => {
await start(); await quiet();
let release!: () => void, created!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
const inserted = new Promise<void>(resolve => { created = resolve; });
let id = "";
const transaction = db.transaction(async tx => {
id = (await create(tx as unknown as Db)).id; created(); await barrier;
});
await inserted;
await vi.advanceTimersByTimeAsync(10_000); expect(provider).not.toHaveBeenCalled();
expect(await stored(id)).toBeUndefined(); release(); await transaction;
await waitFor(async () => expect((await stored(id)).status).toBe("accepted")); await quiet();
const prior = provider.mock.calls.length;
await expect(db.transaction(async tx => { await create(tx as unknown as Db, 41); throw new Error("rollback"); })).rejects.toThrow("rollback");
await quiet(); expect(provider).toHaveBeenCalledTimes(prior); expect(errors).toEqual([]);
});
it("retains provider failures across drain, resumes without overlap, and clears retry after success", async () => {
provider.mockRejectedValue(new Error("GitHub unavailable")); await start(); const card = await create();
await waitFor(() => expect(errors).toHaveLength(1)); await settle();
expect((await stored(card.id)).status).toBe("pending");
draining = true;
const reads = vi.spyOn(db, "select"), calls = provider.mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
expect(reads).not.toHaveBeenCalled(); expect(provider).toHaveBeenCalledTimes(calls); reads.mockRestore();
draining = false; provider.mockResolvedValue("merged");
await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
expect(wakeup).toHaveBeenCalledTimes(1);
});
it("keeps first work committed during drain and startup during standby until resume", async () => {
standby = true; await start(); const card = await create();
await vi.advanceTimersByTimeAsync(60_000); expect(provider).not.toHaveBeenCalled();
standby = false; draining = true;
await vi.advanceTimersByTimeAsync(60_000); expect(provider).not.toHaveBeenCalled();
draining = false;
await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
});
it("reconstructs pending cards on restart and ignores terminal or hidden tasks", async () => {
provider.mockResolvedValue("open"); const card = await create(); await start();
await waitFor(() => expect(provider).toHaveBeenCalledTimes(1)); await settle();
await coordinator!.stop(); coordinator = undefined; provider.mockResolvedValue("merged");
await start(); await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
const hidden = await db.transaction(async tx => {
const card = await create(tx as unknown as Db, 41);
await issueService(tx as unknown as Db).update(issueId, { hiddenAt: new Date() });
return card;
});
await quiet(); expect((await stored(hidden.id)).status).toBe("pending");
await issueService(db).update(issueId, { hiddenAt: null });
await waitFor(async () => expect((await stored(hidden.id)).status).toBe("accepted")); await quiet();
});
it("preserves a real chat binding's durable continuation outbox on automatic acceptance", async () => {
const applicationId = randomUUID(), connectionId = randomUUID(), endpointId = randomUUID();
await db.insert(toolApplications).values({ id: applicationId, companyId, applicationKey: `chat:slack:${endpointId}`, name: "Slack", type: "chat", status: "active" });
await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Slack", uid: endpointId,
connectionPurpose: "channel", transport: "chat_sdk", status: "active", enabled: true });
await db.insert(chatEndpoints).values({ id: endpointId, companyId, connectionId, provider: "slack", publicId: endpointId,
assignedAgentId: agentId, status: "active" });
const [conversation] = await db.insert(chatConversations).values({ companyId, endpointId, issueId,
externalConversationId: "merge-channel", externalThreadId: "merge-thread", externalLabel: "Merge thread", state: "active" }).returning();
await start(); await quiet(); const card = await create();
await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
expect(await db.select().from(chatActions).where(eq(chatActions.kind, "interaction_wakeup"))).toEqual([
expect.objectContaining({ companyId, endpointId, conversationId: conversation.id, providerActionId: `interaction_wakeup:${card.id}`,
payload: expect.objectContaining({ interactionId: card.id, issueId, agentId, interactionStatus: "accepted" }) }),
]);
expect(await db.select().from(chatPublications)).toEqual(expect.arrayContaining([
expect.objectContaining({ idempotencyKey: `interaction:${card.id}:${endpointId}`, state: "cancelled" }),
]));
expect(errors).toEqual([]);
});
it("does not lose a newer card created during provider observation or wake the superseded card", async () => {
let release!: () => void, observed!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
const observing = new Promise<void>(resolve => { observed = resolve; });
provider.mockImplementationOnce(async () => { observed(); await barrier; return "merged"; });
await start(); const old = await create();
await vi.advanceTimersByTimeAsync(1000); await observing;
const current = await create(db, 41);
expect((await stored(old.id)).status).toBe("expired");
release();
await waitFor(async () => expect((await stored(current.id)).status).toBe("accepted")); await quiet();
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({ idempotencyKey: `interaction:${current.id}:accepted` }));
expect(errors).toEqual([]);
});
it("keeps unknown provider results as retryable debt and does not act before retry", async () => {
provider.mockResolvedValue("unknown"); await start(); const card = await create();
await waitFor(() => expect(errors).toHaveLength(1));
const next = coordinator!.nextWakeAt()!;
await vi.advanceTimersByTimeAsync(next - Date.now() - 1); expect(provider).toHaveBeenCalledTimes(1);
provider.mockResolvedValue("merged"); await vi.advanceTimersByTimeAsync(1);
await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
expect(provider).toHaveBeenCalledTimes(2);
});
it("honors terminal issues, pure merge URL targets and continuationPolicy none", async () => {
await start(); await quiet();
const target = await service().create({ id: issueId, companyId }, { ...input(), title: "Merge the linked PR?", continuationPolicy: "none",
payload: { version: 1, prompt: "Merge the PR?", target: { type: "custom", key: "github-pr-39", href: "https://github.com/paperclipai/paperclip/pull/39" } } }, { agentId });
await waitFor(async () => expect((await stored(target.id)).status).toBe("accepted")); await quiet();
expect(wakeup).not.toHaveBeenCalled();
provider.mockClear();
const terminal = await db.transaction(async tx => {
const card = await create(tx as unknown as Db, 41);
await issueService(tx as unknown as Db).update(issueId, { status: "cancelled" });
return card;
});
await quiet(); expect((await stored(terminal.id)).status).toBe("expired");
expect(provider).not.toHaveBeenCalled(); expect(wakeup).not.toHaveBeenCalled();
});
it("rechecks hidden state under the acceptance lock when visibility changes during a provider read", async () => {
let release!: () => void, observed!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
const observing = new Promise<void>(resolve => { observed = resolve; });
provider.mockImplementationOnce(async () => { observed(); await barrier; return "merged"; });
await start(); const card = await create(); await vi.advanceTimersByTimeAsync(1000); await observing;
await issueService(db).update(issueId, { hiddenAt: new Date() }); release(); await quiet();
expect((await stored(card.id)).status).toBe("pending"); expect(wakeup).not.toHaveBeenCalled();
await issueService(db).update(issueId, { hiddenAt: null });
await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
expect(wakeup).toHaveBeenCalledTimes(1); expect(errors).toEqual([]);
});
it("unrelated pending confirmations do not retain worker SQL or provider work", async () => {
await start(); await quiet();
await service().create({ id: issueId, companyId }, { ...input(), title: "Approve the plan?", payload: { version: 1, prompt: "Approve the plan?" } }, { agentId });
await quiet(); expect(provider).not.toHaveBeenCalled(); expect(wakeup).not.toHaveBeenCalled();
expect(errors).toEqual([]);
});
it("awaits an in-flight observation at shutdown and reconstructs its unaccepted card on restart", async () => {
let release!: () => void, observed!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
const observing = new Promise<void>(resolve => { observed = resolve; });
provider.mockImplementationOnce(async () => { observed(); await barrier; return "merged"; });
await start(); const card = await create(); await vi.advanceTimersByTimeAsync(1000); await observing;
let stopped = false;
const stopping = coordinator!.stop().then(() => { stopped = true; });
await delay(10); expect(stopped).toBe(false); release(); await stopping; coordinator = undefined;
expect((await stored(card.id)).status).toBe("pending"); expect(wakeup).not.toHaveBeenCalled();
const reads = vi.spyOn(db, "select");
await vi.advanceTimersByTimeAsync(2 * MERGE_CONFIRMATION_RECONCILE_MS); expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
await start(); await waitFor(async () => expect((await stored(card.id)).status).toBe("accepted")); await quiet();
expect(wakeup).toHaveBeenCalledTimes(1); expect(errors).toEqual([]);
});
it("limits normalized merge hints to the owning company and matching PR and tolerates duplicates", async () => {
provider.mockResolvedValue("open"); const own = await create();
const foreignCompany = randomUUID(), foreignIssue = randomUUID();
await db.insert(companies).values({ id: foreignCompany, name: "Other company", issuePrefix: "OTH" });
await db.insert(issues).values({ id: foreignIssue, companyId: foreignCompany, title: "Other", status: "todo" });
const foreign = await service().create({ id: foreignIssue, companyId: foreignCompany }, input(), { userId: "board" });
const local = service();
const hint = [{ companyId, owner: "PAPERCLIPAI", repo: "paperclip", number: 39 }];
expect((await local.sweepMergedPullRequestConfirmations(hint)).accepted).toBe(1);
expect((await stored(own.id)).status).toBe("accepted"); expect((await stored(foreign.id)).status).toBe("pending");
expect(provider).not.toHaveBeenCalled();
expect((await local.sweepMergedPullRequestConfirmations(hint)).accepted).toBe(0); expect(wakeup).toHaveBeenCalledTimes(1);
});
});
@@ -180,6 +180,7 @@ const {
const heartbeatServiceFactoryMock = vi.fn(() => heartbeatServiceMock);
const issueThreadInteractionServiceMock = {
sweepSupersededPendingRequestConfirmations: vi.fn(async () => ({ expired: 0 })),
mergedPullRequestConfirmationCandidates: vi.fn(async () => []),
sweepMergedPullRequestConfirmations: vi.fn(async () => ({
checked: 0,
candidates: 0,
@@ -1334,7 +1335,9 @@ describe("startServer feedback export wiring", () => {
expect(heartbeatServiceMock.tickAgentTimers).toHaveBeenCalledTimes(1);
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).toHaveBeenCalledTimes(1);
expect(issueThreadInteractionServiceMock.sweepMergedPullRequestConfirmations).toHaveBeenCalledTimes(1);
expect(issueThreadInteractionServiceMock.mergedPullRequestConfirmationCandidates).toHaveBeenCalledTimes(1);
expect(issueThreadInteractionServiceMock.sweepMergedPullRequestConfirmations).not.toHaveBeenCalled();
expect(deliveryWorkRegisterMock.mock.calls.some(([queue]) => queue === DELIVERY_QUEUES.mergedPullRequestConfirmations)).toBe(true);
expect(executionWorkspaceServiceMock.sweepTerminalWorkspaces).toHaveBeenCalledTimes(1);
expect(routineServiceMock.tickScheduledTriggers).toHaveBeenCalledTimes(1);
const routineWorkerIndex = deliveryWorkRegisterMock.mock.calls.findIndex(([queue]) => queue === DELIVERY_QUEUES.routineSchedules);
+3 -14
View File
@@ -1,3 +1,4 @@
import { registerMergeConfirmationWork } from "./services/merged-pr-confirmation-work.js";
import { registerProviderTraceRetentionWork } from "./services/provider-trace-retention-work.js";
import { registerRunEvidenceMigrationWork } from "./services/run-evidence-migration-work.js";
import { registerSkillVersionRetentionWork } from "./services/skill-version-retention-work.js";
@@ -1600,19 +1601,8 @@ async function startServerWithDatabaseTeardown(
: config.workspaceCompactionDelayHours * 60 * 60 * 1000,
terminalWorkspaceInspectionBackoffMs: TERMINAL_WORKSPACE_RETRY_MS,
});
const scheduleMergedPullRequestConfirmationSweep = () => {
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(mergedPullRequestConfirmations
.sweepMergedPullRequestConfirmations()
.then((result) => {
if (result.accepted > 0) {
logger.info(result, "accepted merge confirmations for merged pull requests");
}
})
.catch((err) => {
logger.error({ err }, "merged pull-request confirmation sweep failed");
}));
};
await registerMergeConfirmationWork(deliveryWork, mergedPullRequestConfirmations,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
const gitRestorePointWorker = registerGitRestorePointWork(deliveryWork, gitRestorePointRetentionService(db as any, {
storage: storageService,
retentionDays: config.gitRestorePointRetentionDays,
@@ -2005,7 +1995,6 @@ async function startServerWithDatabaseTeardown(
scheduleExternalObjectRefreshSweep(new Date());
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
scheduleMergedPullRequestConfirmationSweep();
scheduleGitHubConnectionEventPoll();
scheduleGitHubConnectionContinuitySweep();
@@ -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);
}