diff --git a/server/src/modules/wake-queue/adapters/postgres.test.ts b/server/src/modules/wake-queue/adapters/postgres.test.ts index 9bc674d5fac..1e13681d345 100644 --- a/server/src/modules/wake-queue/adapters/postgres.test.ts +++ b/server/src/modules/wake-queue/adapters/postgres.test.ts @@ -245,6 +245,52 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => { expect(checked).toBe(true); }); + it("skips malformed queued comment ids instead of failing the whole liveness batch", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent({ companyId }); + const issueId = await seedIssue({ companyId, assigneeAgentId: agentId }); + const runId = await seedRun({ companyId, agentId, status: "succeeded", contextSnapshot: { issueId } }); + const [comment] = await db.insert(issueComments).values({ + companyId, issueId, authorAgentId: agentId, createdByRunId: null, body: "Mentioned follow-up", + }).returning(); + const missingCommentId = randomUUID(); + // An 8-char short form, like the id observed in a live wake payload. It is + // not a uuid, so it can never match a comment row, but it used to abort the + // whole `inArray` statement and block every queued wake for the issue. + const malformedCommentId = "0422d095"; + const adapter = createPostgresWakeQueueAdapter(db, stubDeps); + let result: { liveNonSelfCommentIds: string[]; containedSelfAuthoredComment: boolean } | null = null; + await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (_locked, ports) => { + result = await ports.transaction.getQueuedCommentLiveness({ + companyId, issueId, wakeAgentId: agentId, finishingRunId: runId, finishingRunAgentId: agentId, + queuedCommentIds: [comment.id, malformedCommentId, missingCommentId], + }); + return { outcome: { kind: "released" as const }, postCommitEffects: [] }; + }); + expect(result).not.toBeNull(); + expect(result!.liveNonSelfCommentIds).toEqual([comment.id]); + expect(result!.containedSelfAuthoredComment).toBe(false); + }); + + it("treats an all-malformed queued comment id list as nothing live", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent({ companyId }); + const issueId = await seedIssue({ companyId, assigneeAgentId: agentId }); + const runId = await seedRun({ companyId, agentId, status: "succeeded", contextSnapshot: { issueId } }); + const adapter = createPostgresWakeQueueAdapter(db, stubDeps); + let result: { liveNonSelfCommentIds: string[]; containedSelfAuthoredComment: boolean } | null = null; + await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (_locked, ports) => { + result = await ports.transaction.getQueuedCommentLiveness({ + companyId, issueId, wakeAgentId: agentId, finishingRunId: runId, finishingRunAgentId: agentId, + queuedCommentIds: ["0422d095"], + }); + return { outcome: { kind: "released" as const }, postCommitEffects: [] }; + }); + expect(result).not.toBeNull(); + expect(result!.liveNonSelfCommentIds).toEqual([]); + expect(result!.containedSelfAuthoredComment).toBe(false); + }); + it.each([false, true])("rechecks disabled chat mode before interrupted queue promotion (conversation=%s)", async (conversation) => { const settings = instanceSettingsService(db); const original = (await settings.getExperimental()).enableAgentChat; diff --git a/server/src/modules/wake-queue/adapters/postgres.ts b/server/src/modules/wake-queue/adapters/postgres.ts index 480d020088d..5d7a7b9eade 100644 --- a/server/src/modules/wake-queue/adapters/postgres.ts +++ b/server/src/modules/wake-queue/adapters/postgres.ts @@ -6,7 +6,7 @@ import { currentConversationCommentCondition } from "../../../services/agent-con import { getExecutionBlocker } from "../../../services/execution-blocker.js"; import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { extractIssueReferenceIdentifiers } from "@paperclipai/shared"; +import { extractIssueReferenceIdentifiers, isUuidLike } from "@paperclipai/shared"; import { activityLog, agentWakeupRequests, @@ -285,12 +285,20 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru }, async getQueuedCommentLiveness({ companyId, issueId, wakeAgentId, finishingRunId, finishingRunAgentId, queuedCommentIds }) { - const rows = await tx + // Malformed ids (for example an 8-char short form written into a wake + // payload) can never match a comment row, but Postgres rejects the whole + // `inArray` statement on the first invalid uuid and blocks every queued + // wake for the issue. Skip them up front so one bad id cannot take down + // the batch; the liveness filters below already treat them as not live. + const queryableCommentIds = queuedCommentIds + .map((commentId) => commentId.trim()) + .filter((commentId) => isUuidLike(commentId)); + const rows = queryableCommentIds.length === 0 ? [] : await tx .select({ id: issueComments.id, deletedAt: issueComments.deletedAt, createdByRunId: issueComments.createdByRunId }) .from(issueComments) - .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, queuedCommentIds), currentConversationCommentCondition())); + .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, queryableCommentIds), currentConversationCommentCondition())); const targetsFinishingRunAgent = wakeAgentId === finishingRunAgentId; - const liveNonSelfCommentIds = queuedCommentIds.filter((commentId) => { + const liveNonSelfCommentIds = queryableCommentIds.filter((commentId) => { const row = rows.find((candidate) => candidate.id === commentId); return Boolean(row && !row.deletedAt && (!targetsFinishingRunAgent || row.createdByRunId !== finishingRunId)); });