/** * ProjectAgentLock — ADR-0002 invariant enforcement at the DB layer. * * `projectId @id` gives at-most-one lock per project; `runId @unique` gives a * run holds at most one lock. Those are DB-level. The spec's `WellFormed` * invariant ("holder is a non-terminal run") is app-level: it must hold on * every read of the lock table. `assertLockHolderActive` enforces it on the * read path — if a lock exists but its run is terminal, that's a bug (the run * should have released it), surfaced as an error rather than silently trusted. */ import type { PrismaClient } from "@prisma/client"; import type { AgentRunStatus } from "@prisma/client"; /// spec RunState.Terminal: completed/failed/timedOut/canceled. /// active/waitingForUser are NOT terminal — the lock must still be held. const TERMINAL_STATUSES: ReadonlySet = new Set([ "COMPLETED", "FAILED", "TIMED_OUT", "CANCELED", ]); export function isTerminal(status: AgentRunStatus): boolean { return TERMINAL_STATUSES.has(status); } /** * Acquire the project lock for a run. Throws if the project is already locked * by another run (ADR-0002 exclusivity). The unique runId constraint means a * run that already holds a lock elsewhere will also fail — that's correct, a * run scopes to one project. */ export async function acquireLock( prisma: PrismaClient, projectId: string, runId: string, holderUserId: string | null, ): Promise { await prisma.projectAgentLock.create({ data: { projectId, runId, holderUserId }, }); } /** * Release the lock owned by a run. Idempotent — a no-op if no lock exists. * Called on run completion/failure/timeout/cancellation (ADR-0002). */ export async function releaseLock(prisma: PrismaClient, runId: string): Promise { await prisma.projectAgentLock.deleteMany({ where: { runId } }); } /** * The spec's `LockTable.WellFormed` on a single read: if `projectId` has a * lock owned by `runId`, that run must be non-terminal. Throws if a terminal * run still holds a lock — that's a release-path bug, not a recoverable state. * * Returns the lock's runId if a healthy lock exists, or null if unlocked. */ export async function currentLockRunId( prisma: PrismaClient, projectId: string, ): Promise { const lock = await prisma.projectAgentLock.findUnique({ where: { projectId }, select: { runId: true }, }); if (lock === null) return null; const run = await prisma.agentRun.findUnique({ where: { id: lock.runId }, select: { status: true }, }); if (run !== null && isTerminal(run.status)) { throw new Error( `lock invariant violated: project ${projectId} locked by terminal run ${lock.runId} (status ${run.status}) — release path bug (ADR-0002 WellFormed)`, ); } return lock.runId; }