Skip to content

Commit 266b07d

Browse files
authored
fix(miner): fail acquire() on an attempt_id/repo mismatch (#8964)
1 parent bda83ca commit 266b07d

2 files changed

Lines changed: 22 additions & 1 deletion

File tree

packages/loopover-miner/lib/worktree-allocator.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,16 @@ export function openWorktreeAllocator(options: {
337337
const normalizedAttempt = normalizeAttemptId(attemptId);
338338
const normalizedRepo = normalizeRepoFullName(repoFullName);
339339
const existing = getByAttempt.get(normalizedAttempt) as WorktreeSlotRow | undefined;
340-
if (existing?.status === "active") return rowToAllocation(existing);
340+
if (existing?.status === "active") {
341+
// #8858: the early-return keys only on attempt_id — guard the repo too, so a second acquire for the same
342+
// attempt but a DIFFERENT repo fails loudly instead of silently handing back the first repo's allocation.
343+
if (existing.repo_full_name !== normalizedRepo) {
344+
throw new Error(
345+
`attempt_id_repo_mismatch: attempt ${normalizedAttempt} is already active for ${existing.repo_full_name}, not ${normalizedRepo}`,
346+
);
347+
}
348+
return rowToAllocation(existing);
349+
}
341350

342351
db.exec("BEGIN IMMEDIATE");
343352
try {

test/unit/miner-worktree-allocator.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ describe("loopover-miner worktree allocator scaffolding (#4298)", () => {
7878
expect(allocator.listSlots().filter((slot) => slot.status === "active")).toHaveLength(2);
7979
});
8080

81+
// #8858: the acquire early-return keyed only on attempt_id. A re-acquire of the same active attempt for a
82+
// DIFFERENT repo used to silently hand back the first repo's allocation; it now fails loudly.
83+
it("throws attempt_id_repo_mismatch when the same active attempt is re-acquired for a different repo (#8858)", () => {
84+
const allocator = tempAllocator({ maxConcurrency: 2 });
85+
const first = allocator.acquire("attempt-a", "acme/widgets");
86+
expect(first.status).toBe("active");
87+
// Same-repo re-acquire stays idempotent (returns the same allocation)...
88+
expect(allocator.acquire("attempt-a", "acme/widgets").worktreePath).toBe(first.worktreePath);
89+
// ...but a different repo for that same active attempt is a mismatch, not a silent stale hand-back.
90+
expect(() => allocator.acquire("attempt-a", "acme/other")).toThrow("attempt_id_repo_mismatch");
91+
});
92+
8193
it("release frees a slot for reuse and rejects invalid input", () => {
8294
const allocator = tempAllocator({ maxConcurrency: 1 });
8395
const first = allocator.acquire("attempt-a", "acme/widgets");

0 commit comments

Comments
 (0)