Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/loopover-miner/lib/worktree-allocator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions test/unit/miner-worktree-allocator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down