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-3396Done

Approving a 15-item plan 500s — materialize runs in ONE interactive transaction on Prisma's DEFAULT 5s budget, and wires blocked_by edges ONE round trip per link

POST /api/plans/cmt3j4ilz002hi6ph3z89vo7p/approve returned 500 on every attempt. The plan is 15 add proposals with 27 blocked_by edges — the expansion of MOTIR-3298. The cards were created directly instead; the plan is still planned and must be DECLINED or it will duplicate them.

What actually happened

fly logs -a motir-core, four failures, all PrismaClientKnownRequestError P2028:

2026-08-21T22:49:55Z  ⨯ P2028: Transaction API error:
   Unable to start a transaction in the given time.

2026-08-21T23:01:49Z  ⨯ P2028: Invalid `prisma.workItemLink.createManyAndReturn()` invocation:
   A query cannot be executed on an expired transaction. The timeout for this
   transaction was 5000 ms, however 5033 ms passed since the start of the transaction.
   meta: { modelName: 'WorkItemLink', operation: 'query', timeout: 5000, timeTaken: 5033 }
   at async Object.createIfAbsent (...)
   (repeated 23:02:03 timeTaken 5034, and 23:02:41 timeTaken 5030)

Both halves of Prisma's default budget bit: the first attempt exhausted maxWait (2 000 ms) waiting for a connection, the next three exhausted timeout (5 000 ms) mid-body, each dying in the blocked_by link pass — the last big batch of round trips.

The route is not at fault. app/api/plans/[id]/approve/route.ts maps every typed plan error to 4xx and re-throws the rest; P2028 is not one of them, so it surfaces as a 500 with no actionable body.

Root cause — two independent contributors

1. approvePlan opens its transaction with no budget, and the helper it uses cannot take one. lib/services/plansService.ts calls withWorkspaceContext(...) around the lock → gate → materialize → onboarding-rename sequence. lib/workspaces/context.ts:86 hardcodes db.$transaction(fn) with no options parameter at all — unlike its sibling withWorkspaceServiceContext (line 250), which already accepts TransactionBudget and applies { timeout: options.timeoutMs, maxWait: options.maxWaitMs }. So the one context every tenant-scoped write uses is the one that cannot raise its budget.

The type and the precedent already exist: TransactionBudget (context.ts:307) documents the 5 000 / 2 000 defaults and says raising them should be "a visible, argued decision at the call site", and projectRunnerGroupService.ts:72 ships SYNC_TX_BUDGET = { timeoutMs: 30_000, maxWaitMs: 10_000 }.

2. The link pass is one network round trip PER EDGE. workItemLinkRepository.createIfAbsent (line 139) takes a single WorkItemLinkUncheckedCreateInput and calls tx.workItemLink.createManyAndReturn({ data: [data], skipDuplicates: true }) — an array API invoked with an array of one. 27 edges is 27 sequential awaits against Neon from Fly iad, on top of 15 × (allocate number + create + status update + body rewrite + auto-relate + record revision). The transaction's own error text names the better fix first: "Consider increasing the interactive transaction timeout or doing less work in the transaction."

Fix contributor 2 before reaching for contributor 1. A batched insert removes ~26 round trips and is the change that makes the budget adequate rather than merely larger.

Scope note — this is a CEILING, not a one-off

15 items is not a large plan. add_plan_items is documented to accept up to 4 096 proposals per call and the shipped generator emits trees layer by layer, so any plan above roughly a dozen items with a dense edge graph is on the wrong side of a 5-second budget. Nothing warns the author: the plan closes to planned cleanly, validate_work_item --planId returns valid: true, and the failure appears only when a person presses Approve.

Acceptance criteria

  • withWorkspaceContext accepts an optional TransactionBudget and applies it exactly as withWorkspaceServiceContext does; omitting it keeps today's Prisma defaults, verified by a test that the option-less call passes undefined.
  • workItemLinkRepository.createIfAbsent gains a BATCH form that inserts N links in ONE createManyAndReturn with skipDuplicates, and materialize's blocked_by pass uses it; the PR body states the round-trip count before and after for a 27-edge plan.
  • approvePlan passes an explicit TransactionBudget with a comment arguing the numbers, in the shape SYNC_TX_BUDGET set.
  • An integration test approves a plan of at least 15 adds and 25 edges and asserts it commits — it must FAIL on main before the fix, and the PR body says so.
  • P2028 maps to a typed 503-or-409 response naming the plan, not a bare 500: an approve that times out tells the caller the transaction was too large, and the message names the item count.
  • The existing approve integration tests stay green and none is weakened.

Context refs

  • motir-core lib/workspaces/context.tswithWorkspaceContext (line 86, no options), withWorkspaceServiceContext (line 250, has them), TransactionBudget (line 307).
  • motir-core lib/services/plansService.tsapprovePlan's withWorkspaceContext call, and materialize (line 747) with its Pass 2 edge wiring.
  • motir-core lib/repositories/workItemLinkRepository.tscreateIfAbsent, line 139.
  • motir-core lib/services/projectRunnerGroupService.tsSYNC_TX_BUDGET, the shipped precedent to copy.
  • motir-core app/api/plans/[id]/approve/route.ts — the error map that has no P2028 arm.
  • motir-core tests/integration/plans/ — the approve suites the new test joins.
  • Plan cmt3j4ilz002hi6ph3z89vo7p — the failing input, still planned; DECLINE it, its 15 cards exist as MOTIR-3381MOTIR-3395.