diff --git a/server/src/__tests__/db-errors.test.ts b/server/src/__tests__/db-errors.test.ts new file mode 100644 index 0000000000..80dbb536cf --- /dev/null +++ b/server/src/__tests__/db-errors.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { isUniqueViolation } from "../db-errors.js"; + +const CONSTRAINT = "issues_open_routine_execution_uq"; + +describe("isUniqueViolation", () => { + it("matches a bare postgres.js unique violation", () => { + expect(isUniqueViolation({ code: "23505", constraint_name: CONSTRAINT }, CONSTRAINT)).toBe(true); + }); + + it("matches the node-postgres constraint field", () => { + expect(isUniqueViolation({ code: "23505", constraint: CONSTRAINT }, CONSTRAINT)).toBe(true); + }); + + it("matches the error Drizzle wraps around the driver failure", () => { + const wrapped = new Error("Failed query: update \"issues\" set \"execution_run_id\" = $1"); + (wrapped as { cause?: unknown }).cause = { code: "23505", constraint_name: CONSTRAINT }; + expect(isUniqueViolation(wrapped, CONSTRAINT)).toBe(true); + }); + + it("falls back to the driver message when the constraint name is not surfaced", () => { + expect(isUniqueViolation({ + cause: { + code: "23505", + message: `duplicate key value violates unique constraint "${CONSTRAINT}"`, + }, + }, CONSTRAINT)).toBe(true); + }); + + it("matches any unique violation when no constraint is named", () => { + expect(isUniqueViolation({ cause: { code: "23505" } })).toBe(true); + }); + + it("ignores a unique violation on a different constraint", () => { + expect(isUniqueViolation({ cause: { code: "23505", constraint_name: "issues_identifier_idx" } }, CONSTRAINT)) + .toBe(false); + }); + + it("ignores errors that are not unique violations", () => { + expect(isUniqueViolation({ cause: { code: "23503", constraint_name: CONSTRAINT } }, CONSTRAINT)).toBe(false); + expect(isUniqueViolation(new Error("boom"), CONSTRAINT)).toBe(false); + expect(isUniqueViolation(null, CONSTRAINT)).toBe(false); + expect(isUniqueViolation(undefined, CONSTRAINT)).toBe(false); + }); + + it("stops walking a self-referential cause chain", () => { + const looped: { cause?: unknown } = {}; + looped.cause = looped; + expect(isUniqueViolation(looped, CONSTRAINT)).toBe(false); + }); +}); diff --git a/server/src/db-errors.ts b/server/src/db-errors.ts new file mode 100644 index 0000000000..667fa65580 --- /dev/null +++ b/server/src/db-errors.ts @@ -0,0 +1,33 @@ +const UNIQUE_VIOLATION = "23505"; +const MAX_CAUSE_DEPTH = 4; + +/** + * Recognizes a Postgres unique-constraint violation (SQLSTATE 23505). + * + * Drizzle wraps driver failures in its own `Failed query: ...` error, so the + * Postgres error that carries the code and the constraint name is reachable + * only through `cause` — inspecting the thrown error directly misses it. The + * constraint name itself lands on `constraint_name` under postgres.js and on + * `constraint` under node-postgres, and is not always surfaced at all, so fall + * back to the driver message. + */ +export function isUniqueViolation(error: unknown, constraintName?: string): boolean { + let current: unknown = error; + for (let depth = 0; depth < MAX_CAUSE_DEPTH && current && typeof current === "object"; depth += 1) { + const candidate = current as { + code?: unknown; + constraint?: unknown; + constraint_name?: unknown; + message?: unknown; + cause?: unknown; + }; + if (candidate.code === UNIQUE_VIOLATION) { + if (!constraintName) return true; + const constraint = candidate.constraint ?? candidate.constraint_name; + if (constraint === constraintName) return true; + if (typeof candidate.message === "string" && candidate.message.includes(constraintName)) return true; + } + current = candidate.cause; + } + return false; +} diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index beb6459dd6..02084fe7cf 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -103,6 +103,7 @@ import { } from "@paperclipai/shared"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; +import { isUniqueViolation } from "../db-errors.js"; import type { StorageService } from "../storage/types.js"; import { validate } from "../middleware/validate.js"; import * as serviceIndex from "../services/index.js"; @@ -9983,7 +9984,18 @@ export function issueRoutes( const checkoutRunId = requireAgentRunId(req, res); if (req.actor.type === "agent" && !checkoutRunId) return; - const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId); + let updated; + try { + updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId); + } catch (error) { + if (isUniqueViolation(error, "issues_open_routine_execution_uq")) { + res.status(409).json({ + error: "Another execution for this routine is already in progress", + }); + return; + } + throw error; + } const actor = getActorInfo(req); if (updated?.harnessKind === "skill_test") { await companySkillsSvc.markTestRunRunning(updated.companyId, updated.id);