Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-2071Done

`startSprint`'s FOR UPDATE guard cannot lock an EMPTY set — the first activation races, and the index's refusal reaches the caller as a raw Prisma error

Found and FIXED while building 11.3.6 — the fix ships in Story 11.3's PR (#1819). Logged as its own card because the defect is in shipped Story 4.4.2 code, not in the API card that surfaced it, and it is reachable from the web app as well as from /api/v1.

The defect

sprintsService.startSprint documents its one-active guard as "the AUTHORITATIVE guard is the FOR UPDATE lock on the project's active sprint INSIDE the activation transaction (TOCTOU close), with the sprint_one_active_per_project partial-unique index as the DB backstop."

That is backwards for the case that matters most. The lock is:

const locked = await sprintRepository.findActiveByProjectForUpdate(projectId, workspaceId, tx);
if (locked) throw new SprintAlreadyActiveError(...);

SELECT … FOR UPDATE over a project with no active sprint matches zero rows, so it locks nothing. On a FIRST activation — the common case — two concurrent starts both see locked === null, both fall through, and both UPDATE. What actually stops the second is the partial-unique index, at COMMIT.

And that refusal was never translated: it reaches the caller as a raw PrismaClientKnownRequestError (P2002). Every transport then renders it as an unexplained 500 — the /api/v1 status map recognises domain codes, not driver codes, and the web app has no handler for it either.

Reproduction (before the fix)

Two startSprint calls issued concurrently against a project with no active sprint, 8 rounds:

attempt 0: REJECTED name=SprintAlreadyActiveError code=SPRINT_ALREADY_ACTIVE
attempt 1: REJECTED name=PrismaClientKnownRequestError code=P2002
attempt 2: REJECTED name=PrismaClientKnownRequestError code=P2002
… (7 of 8 attempts)

7 of 8 losers got the raw error. The lock caught only 1 — which is why the API's concurrency test passed locally and failed in CI (expected [ 200, 500 ] to deeply equal [ 200, 409 ], run 30904086437, shard Vitest (2/3)).

The fix that shipped

In startSprint, the unique violation is caught OUTSIDE the transaction and re-thrown as the SAME typed SprintAlreadyActiveError the in-transaction lock raises, re-reading the winner's id so the error can name the sprint that actually won. One condition, one outcome, however the race was lost. The misleading comment is corrected in place.

completeSprint is NOT affected: it locks the project's active sprint, which by definition exists when completing one — that FOR UPDATE has a row to lock.

Why this stayed hidden

The API test that caught it ran the race ONCE. The two losing paths do not occur with equal probability, so a single round goes green on a developer machine while the common path is broken. The test now runs five rounds and clears the active slot between them, so every round races for a FIRST activation — and it fails at round 0 without the fix (verified by neutering the fix and re-running).

Follow-up worth considering (NOT done here, deliberately)

The translation makes the ERROR correct; it does not make the lock correct. A caller still loses the race at commit rather than at the guard, which is fine for this operation (one loser, one typed refusal, no partial state — the whole activation is one transaction) but would not be for an operation with side effects before the commit. A structural fix — locking the PROJECT row so activations serialize before any work — needs a new repository read and belongs to Epic 4's sprint-lifecycle story, not to the public API story that found this.

Acceptance criteria

  • Two simultaneous startSprint calls against a project with no active sprint yield exactly one success and one SprintAlreadyActiveError — never a raw Prisma error, never two winners. ✅ shipped
  • The error names the sprint that actually won. ✅ shipped
  • The concurrency test drives the race repeatedly, so the common losing path is the one under test, and it fails without the fix. ✅ shipped
  • startSprint's comment no longer claims the FOR UPDATE is authoritative for the first activation. ✅ shipped

Context refs

  • lib/services/sprintsService.tsstartSprint, the lock, and the new translation.
  • lib/repositories/sprintRepository.tsfindActiveByProjectForUpdate, and the sprint_one_active_per_project partial-unique index.
  • tests/api/v1/sprint-lifecycle-routes.test.ts — the repeated-race regression test.
  • Surfaced by: 11.3.6. Fix ships in PR #1819.