diff --git a/packages/loopover-miner/lib/worktree-allocator.ts b/packages/loopover-miner/lib/worktree-allocator.ts index 4ab0375a65..8943b7d38a 100644 --- a/packages/loopover-miner/lib/worktree-allocator.ts +++ b/packages/loopover-miner/lib/worktree-allocator.ts @@ -337,7 +337,16 @@ export function openWorktreeAllocator(options: { const normalizedAttempt = normalizeAttemptId(attemptId); const normalizedRepo = normalizeRepoFullName(repoFullName); const existing = getByAttempt.get(normalizedAttempt) as WorktreeSlotRow | undefined; - if (existing?.status === "active") return rowToAllocation(existing); + if (existing?.status === "active") { + // #8858: the early-return keys only on attempt_id — guard the repo too, so a second acquire for the same + // attempt but a DIFFERENT repo fails loudly instead of silently handing back the first repo's allocation. + if (existing.repo_full_name !== normalizedRepo) { + throw new Error( + `attempt_id_repo_mismatch: attempt ${normalizedAttempt} is already active for ${existing.repo_full_name}, not ${normalizedRepo}`, + ); + } + return rowToAllocation(existing); + } db.exec("BEGIN IMMEDIATE"); try { diff --git a/test/unit/miner-worktree-allocator.test.ts b/test/unit/miner-worktree-allocator.test.ts index 8c6d26763a..07bd6626f3 100644 --- a/test/unit/miner-worktree-allocator.test.ts +++ b/test/unit/miner-worktree-allocator.test.ts @@ -78,6 +78,18 @@ describe("loopover-miner worktree allocator scaffolding (#4298)", () => { expect(allocator.listSlots().filter((slot) => slot.status === "active")).toHaveLength(2); }); + // #8858: the acquire early-return keyed only on attempt_id. A re-acquire of the same active attempt for a + // DIFFERENT repo used to silently hand back the first repo's allocation; it now fails loudly. + it("throws attempt_id_repo_mismatch when the same active attempt is re-acquired for a different repo (#8858)", () => { + const allocator = tempAllocator({ maxConcurrency: 2 }); + const first = allocator.acquire("attempt-a", "acme/widgets"); + expect(first.status).toBe("active"); + // Same-repo re-acquire stays idempotent (returns the same allocation)... + expect(allocator.acquire("attempt-a", "acme/widgets").worktreePath).toBe(first.worktreePath); + // ...but a different repo for that same active attempt is a mismatch, not a silent stale hand-back. + expect(() => allocator.acquire("attempt-a", "acme/other")).toThrow("attempt_id_repo_mismatch"); + }); + it("release frees a slot for reuse and rejects invalid input", () => { const allocator = tempAllocator({ maxConcurrency: 1 }); const first = allocator.acquire("attempt-a", "acme/widgets");