Three findings from PBI-47 review: P1 — primary_worktree_path scheiden van lock-volgorde setupProductWorktrees acquired locks in alphabetical order (deadlock prevention) but also returned worktrees in that order, so worktrees[0] could point at a secondary product when its id sorted before the primary's. Lock-acquire stays sorted; output now preserves caller's input order so worktrees[0] is always the primary. P1 — Idea-claim rollback bij worktree setup failure setupProductWorktrees runs after tryClaimJob has already flipped the job to CLAIMED. A failure in lock-acquire/git-fetch/reset/sync left the job hanging until the 30-min stale-reset and the lock-map populated. Wrapped in try/catch with releaseLocksOnTerminal + rollbackClaim mirror of the task-pad behaviour. P2 — SPRINT mark-ready fallback when last task didn't push The mark-ready path used updated.pr_url, which is null when the closing task was verify-only or had no diff. Now falls back to a Prisma findFirst on the SprintRun's earliest job with pr_url IS NOT NULL. Tests: 31 files, 243 passing (incl. new input-order regression for setupProductWorktrees). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73 lines
2.5 KiB
TypeScript
73 lines
2.5 KiB
TypeScript
import * as fs from 'node:fs/promises'
|
|
import * as path from 'node:path'
|
|
import { acquireFileLocksOrdered } from './file-lock.js'
|
|
import {
|
|
getProductWorktreeLockPath,
|
|
getWorktreeRoot,
|
|
} from './worktree-paths.js'
|
|
import {
|
|
getOrCreateProductWorktree,
|
|
syncProductWorktree,
|
|
} from './product-worktree.js'
|
|
|
|
type JobReleases = Map<string, Array<() => Promise<void>>>
|
|
const jobReleases: JobReleases = new Map()
|
|
|
|
export async function setupProductWorktrees(
|
|
jobId: string,
|
|
productIds: string[],
|
|
resolveRepoRoot: (productId: string) => Promise<string | null>,
|
|
): Promise<Array<{ productId: string; worktreePath: string }>> {
|
|
if (productIds.length === 0) return []
|
|
|
|
// Ensure parent dir exists so lockfile creation succeeds
|
|
await fs.mkdir(path.join(getWorktreeRoot(), '_products'), { recursive: true })
|
|
|
|
// Lock-first, alphabetically sorted (deadlock prevention for multi-product idea-jobs).
|
|
// Locks acquired in sorted order; output preserves caller's input order so that
|
|
// worktrees[0] is the primary product (Idea.product_id), regardless of how its
|
|
// id sorts alphabetically against secondary products.
|
|
const sorted = [...productIds].sort()
|
|
const lockPaths = sorted.map(getProductWorktreeLockPath)
|
|
const releaseAll = await acquireFileLocksOrdered(lockPaths)
|
|
registerJobLockReleases(jobId, [releaseAll])
|
|
|
|
// After lock-acquire, create/reuse worktrees and sync — iterate input order
|
|
// so callers get back [primary, ...secondaries] in their original sequence.
|
|
const out: Array<{ productId: string; worktreePath: string }> = []
|
|
for (const productId of productIds) {
|
|
const repoRoot = await resolveRepoRoot(productId)
|
|
if (!repoRoot) continue
|
|
const { worktreePath } = await getOrCreateProductWorktree({ repoRoot, productId })
|
|
await syncProductWorktree({ worktreePath })
|
|
out.push({ productId, worktreePath })
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
export function registerJobLockReleases(
|
|
jobId: string,
|
|
releases: Array<() => Promise<void>>,
|
|
): void {
|
|
const existing = jobReleases.get(jobId) ?? []
|
|
jobReleases.set(jobId, [...existing, ...releases])
|
|
}
|
|
|
|
export async function releaseLocksOnTerminal(jobId: string): Promise<void> {
|
|
const releases = jobReleases.get(jobId)
|
|
if (!releases) return // idempotent — already released or never locked
|
|
jobReleases.delete(jobId)
|
|
for (const release of releases) {
|
|
try {
|
|
await release()
|
|
} catch (err) {
|
|
console.warn(`[job-locks] release failed for job ${jobId}:`, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// For tests
|
|
export function _resetJobReleasesForTest(): void {
|
|
jobReleases.clear()
|
|
}
|