diff --git a/packages/loopover-miner/lib/purge-cli.ts b/packages/loopover-miner/lib/purge-cli.ts index d61b8ee805..3287ca0e81 100644 --- a/packages/loopover-miner/lib/purge-cli.ts +++ b/packages/loopover-miner/lib/purge-cli.ts @@ -37,6 +37,8 @@ import { openReplaySnapshotStore, resolveReplaySnapshotDbPath } from "./replay-s import type { ReplaySnapshotStore } from "./replay-snapshot.js"; import { initDenyHookSynthesisStore, resolveDenyHookSynthesisDbPath } from "./deny-hook-synthesis.js"; import type { DenyHookSynthesisStore } from "./deny-hook-synthesis.js"; +import { countWorktreeSlotsToPurge, openWorktreeAllocator, resolveWorktreeAllocatorDbPath } from "./worktree-allocator.js"; +import type { WorktreeAllocator } from "./worktree-allocator.js"; import { resolveAttemptLogDbPath } from "./attempt-log.js"; import { CLAIM_LEDGER_PURGE_SPEC, @@ -79,7 +81,8 @@ type PurgeOpenerKey = | "initPolicyVerdictCacheStore" | "initRankedCandidatesStore" | "openReplaySnapshotStore" - | "initDenyHookSynthesisStore"; + | "initDenyHookSynthesisStore" + | "openWorktreeAllocator"; export type PurgeCliOptions = { openClaimLedger?: () => ClaimLedger; @@ -94,6 +97,7 @@ export type PurgeCliOptions = { initRankedCandidatesStore?: () => RankedCandidatesStore; openReplaySnapshotStore?: () => ReplaySnapshotStore; initDenyHookSynthesisStore?: () => DenyHookSynthesisStore; + openWorktreeAllocator?: () => WorktreeAllocator; resolveDbPaths?: Record string>; }; @@ -104,6 +108,10 @@ type PurgeTarget = { resolveDbPath: () => string; spec?: LedgerPurgeSpec; specs?: LedgerPurgeSpec[]; + // A store whose per-repo purge lives on its store object (not a generic DELETE spec) supplies its own read-only + // dry-run counter instead of `spec`/`specs`, so --dry-run counts EXACTLY the rows the real purge would touch + // (e.g. worktree-allocator, whose fixed slot pool is cleared with a status-guarded UPDATE, never a DELETE). + countDryRun?: (db: DatabaseSync, repoFullName: string) => number; }; const REAL_PURGE_TARGETS: PurgeTarget[] = [ @@ -124,6 +132,11 @@ const REAL_PURGE_TARGETS: PurgeTarget[] = [ { name: "ranked-candidates", optionKey: "initRankedCandidatesStore", opener: initRankedCandidatesStore, resolveDbPath: resolveRankedCandidatesDbPath, spec: RANKED_CANDIDATES_PURGE_SPEC }, { name: "replay-snapshot", optionKey: "openReplaySnapshotStore", opener: openReplaySnapshotStore, resolveDbPath: resolveReplaySnapshotDbPath, spec: REPLAY_SNAPSHOT_PURGE_SPEC }, { name: "deny-hook-synthesis", optionKey: "initDenyHookSynthesisStore", opener: initDenyHookSynthesisStore, resolveDbPath: resolveDenyHookSynthesisDbPath, spec: DENY_HOOK_SYNTHESIS_PURGE_SPEC }, + // worktree-allocator's `worktree_slots` is a FIXED pool of pre-allocated slot rows, not an append-only ledger, so + // the generic DELETE-based spec is the wrong shape (deleting a slot would shrink the pool below maxConcurrency). + // Its purge lives on the store object (a `status = 'free'` UPDATE blanking the repo columns, never touching a + // live `active` slot), and its dry-run count uses the matching read-only counter — no `spec`/`specs` (#8320). + { name: "worktree-allocator", optionKey: "openWorktreeAllocator", opener: openWorktreeAllocator, resolveDbPath: resolveWorktreeAllocatorDbPath, countDryRun: countWorktreeSlotsToPurge }, ]; export type ParsedPurgeArgs = { json: boolean; dryRun: boolean; repoFullName: string } | { error: string }; @@ -216,13 +229,14 @@ export function runPurgeDryRun( const resolveDbPaths = options.resolveDbPaths ?? {}; const stores: PurgeDryRunStoreResult[] = REAL_PURGE_TARGETS.map((target) => { const dbPath = (resolveDbPaths[target.name] ?? target.resolveDbPath)(); - // A target scopes one table (`spec`) or -- for governor-state -- several in one file (`specs`); sum the - // per-table counts against the single read-only handle so the preview matches what a real purge removes. - // Every REAL_PURGE_TARGETS entry declares exactly one of the two, so `target.spec` is always set here. - const specs = target.specs ?? [target.spec!]; + // A target scopes one table (`spec`), several in one file (`specs`, e.g. governor-state), or supplies its own + // read-only counter (`countDryRun`, e.g. worktree-allocator's status-guarded slot count). Every entry declares + // exactly one of the three, so the `target.spec!` fallback below is only reached when neither other is set. try { const wouldPurge = countExistingRows(dbPath, (db) => - specs.reduce((sum, spec) => sum + countStoreByRepo(db, spec, parsed.repoFullName), 0), + target.countDryRun + ? target.countDryRun(db, parsed.repoFullName) + : (target.specs ?? [target.spec!]).reduce((sum, spec) => sum + countStoreByRepo(db, spec, parsed.repoFullName), 0), ); return { store: target.name, wouldPurge }; } catch (error) { diff --git a/packages/loopover-miner/lib/worktree-allocator.ts b/packages/loopover-miner/lib/worktree-allocator.ts index ac7cd40dfb..6eeaa22fef 100644 --- a/packages/loopover-miner/lib/worktree-allocator.ts +++ b/packages/loopover-miner/lib/worktree-allocator.ts @@ -38,6 +38,7 @@ export type WorktreeAllocator = { acquire(attemptId: string, repoFullName: string): WorktreeAllocation; release(attemptId: string): WorktreeAllocation | null; listSlots(): WorktreeAllocation[]; + purgeByRepo(repoFullName: string): number; close(): void; }; @@ -304,6 +305,16 @@ export function openWorktreeAllocator(options: { const listSlots = db.prepare( "SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots ORDER BY slot_index", ); + // Right-to-be-forgotten backstop (#8320): blank a FREE slot's stale repo columns (mirroring release()'s own + // clearing UPDATE), never DELETE — deleting a row would shrink the fixed pool below maxConcurrency and break + // ensureSlots/selectFreeSlot's every-slot-exists invariant. The `status = 'free'` guard is what protects a live + // in-flight attempt: an `active` slot's repo_full_name reflects a real on-disk worktree checkout, so clearing it + // would desync the allocator from that checkout — such a row is never matched here. + const purgeFreeByRepo = db.prepare(` + UPDATE worktree_slots + SET repo_full_name = NULL, attempt_id = NULL, owner_pid = NULL, owner_host = NULL, allocated_at = NULL + WHERE status = 'free' AND repo_full_name = ? + `); const allocator: WorktreeAllocator = { dbPath: resolvedPath, @@ -358,6 +369,14 @@ export function openWorktreeAllocator(options: { listSlots() { return (listSlots.all() as WorktreeSlotRow[]).map(rowToAllocation); }, + purgeByRepo(repoFullName) { + // Normalize the same way acquire() persisted it (owner/repo), so the WHERE clause matches — mirroring + // governor-state.ts's own purgeByRepo. Expected to affect 0 rows in the overwhelming majority of real + // calls: only a `free` slot left carrying a stale repo_full_name (a crash between acquire and the normal + // clear) can match, since release()/reclaimOrphanedAllocations() already blank these fields on every free. + const normalizedRepo = normalizeRepoFullName(repoFullName); + return Number(purgeFreeByRepo.run(normalizedRepo).changes); + }, close() { db.close(); }, @@ -366,6 +385,20 @@ export function openWorktreeAllocator(options: { return allocator; } +/** + * Read-only count of the rows {@link WorktreeAllocator.purgeByRepo} would clear for one repo — `free` slots still + * carrying a stale `repo_full_name`, never an `active` slot (whose repo reflects a live in-flight checkout). Runs + * against a bare read-only handle (purge-cli.js's `--dry-run` path opens the file itself), so it takes a + * `DatabaseSync` rather than the allocator object, and its WHERE clause matches `purgeByRepo`'s EXACTLY so the + * dry-run preview equals what a real purge removes. + */ +export function countWorktreeSlotsToPurge(db: DatabaseSync, repoFullName: string): number { + const row = db + .prepare("SELECT COUNT(*) AS count FROM worktree_slots WHERE status = 'free' AND repo_full_name = ?") + .get(repoFullName) as CountRow | undefined; + return Number(row?.count); +} + function getDefaultWorktreeAllocator(): WorktreeAllocator { defaultWorktreeAllocator ??= openWorktreeAllocator(); return defaultWorktreeAllocator; diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index e7f3231cd2..98ae1ef372 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -1372,6 +1372,7 @@ describe("runAttempt (#5132)", () => { }, release: vi.fn(), listSlots: () => [], + purgeByRepo: vi.fn(() => 0), close: vi.fn(), }), openClaimLedger: () => claimLedger, diff --git a/test/unit/miner-purge-cli.test.ts b/test/unit/miner-purge-cli.test.ts index 1355f0b8a5..91a8752c64 100644 --- a/test/unit/miner-purge-cli.test.ts +++ b/test/unit/miner-purge-cli.test.ts @@ -1,7 +1,9 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { openWorktreeAllocator } from "../../packages/loopover-miner/lib/worktree-allocator.js"; import { openClaimLedger, closeDefaultClaimLedger } from "../../packages/loopover-miner/lib/claim-ledger.js"; import { initEventLedger, closeDefaultEventLedger } from "../../packages/loopover-miner/lib/event-ledger.js"; import { initGovernorLedger, closeDefaultGovernorLedger } from "../../packages/loopover-miner/lib/governor-ledger.js"; @@ -222,6 +224,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "ranked-candidates": () => rankedCandidatesDbPath, "replay-snapshot": () => replaySnapshotDbPath, "deny-hook-synthesis": () => denyHookSynthesisDbPath, + // Never created — dry run counts 0 free-stale slots and must not touch it (a real active slot is a live + // in-flight attempt and is never purgeable anyway). + "worktree-allocator": () => join(root, "worktree-allocator.sqlite3"), "attempt-log": () => attemptLogDbPath, }; @@ -245,6 +250,7 @@ describe("runPurge --dry-run (#5564, #6599)", () => { { store: "ranked-candidates", wouldPurge: 1 }, { store: "replay-snapshot", wouldPurge: 1 }, { store: "deny-hook-synthesis", wouldPurge: 1 }, + { store: "worktree-allocator", wouldPurge: 0 }, ], attemptLogNote: ATTEMPT_LOG_NOT_PURGEABLE_NOTE, attemptLogTotalRows: 0, @@ -280,12 +286,13 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "ranked-candidates": () => join(root, "ranked-candidates.sqlite3"), "replay-snapshot": () => join(root, "replay-snapshot.sqlite3"), "deny-hook-synthesis": () => join(root, "deny-hook-synthesis.sqlite3"), + "worktree-allocator": () => join(root, "worktree-allocator.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"], { resolveDbPaths })).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); - expect(result.stores).toHaveLength(12); + expect(result.stores).toHaveLength(13); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); expect(result.attemptLogTotalRows).toBe(0); for (const resolve of Object.values(resolveDbPaths)) { @@ -357,6 +364,7 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "ranked-candidates": () => join(root, "ranked-candidates.sqlite3"), "replay-snapshot": () => join(root, "replay-snapshot.sqlite3"), "deny-hook-synthesis": () => join(root, "deny-hook-synthesis.sqlite3"), + "worktree-allocator": () => join(root, "worktree-allocator.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -401,6 +409,7 @@ describe("runPurge --dry-run (#5564, #6599)", () => { LOOPOVER_MINER_RANKED_CANDIDATES_DB: process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB, LOOPOVER_MINER_REPLAY_SNAPSHOT_DB: process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB, LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB: process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB, + LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB: process.env.LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB, LOOPOVER_MINER_ATTEMPT_LOG_DB: process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB, }; process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB = join(root, "claim-ledger.sqlite3"); @@ -415,12 +424,13 @@ describe("runPurge --dry-run (#5564, #6599)", () => { process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB = join(root, "ranked-candidates.sqlite3"); process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB = join(root, "replay-snapshot.sqlite3"); process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB = join(root, "deny-hook-synthesis.sqlite3"); + process.env.LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB = join(root, "worktree-allocator.sqlite3"); process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB = join(root, "attempt-log.sqlite3"); try { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"])).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); - expect(result.stores).toHaveLength(12); + expect(result.stores).toHaveLength(13); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); // Nothing was created — dry run against nonexistent default-path stores makes zero writes. expect(existsSync(process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB)).toBe(false); @@ -462,6 +472,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => fakeStore(0), openReplaySnapshotStore: () => fakeStore(0), initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -484,6 +495,7 @@ describe("runPurge (real, #5564, #6599)", () => { { store: "ranked-candidates", purged: 0 }, { store: "replay-snapshot", purged: 0 }, { store: "deny-hook-synthesis", purged: 0 }, + { store: "worktree-allocator", purged: 0 }, { store: "attempt-log", purged: null, note: ATTEMPT_LOG_NOT_PURGEABLE_NOTE }, ], }); @@ -528,6 +540,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => fakeStore(0), openReplaySnapshotStore: () => fakeStore(0), initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -567,6 +580,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => fakeStore(0), openReplaySnapshotStore: () => fakeStore(0), initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -590,6 +604,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => fakeStore(0), openReplaySnapshotStore: () => fakeStore(0), initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -615,6 +630,8 @@ describe("runPurge (real, #5564, #6599)", () => { LOOPOVER_MINER_RANKED_CANDIDATES_DB: process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB, LOOPOVER_MINER_REPLAY_SNAPSHOT_DB: process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB, LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB: process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB, + LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB: process.env.LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB, + LOOPOVER_MINER_WORKTREE_DIR: process.env.LOOPOVER_MINER_WORKTREE_DIR, }; const claimDbPath = join(root, "claim-ledger.sqlite3"); const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); @@ -631,6 +648,8 @@ describe("runPurge (real, #5564, #6599)", () => { process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB = join(root, "ranked-candidates.sqlite3"); process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB = join(root, "replay-snapshot.sqlite3"); process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB = join(root, "deny-hook-synthesis.sqlite3"); + process.env.LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB = join(root, "worktree-allocator.sqlite3"); + process.env.LOOPOVER_MINER_WORKTREE_DIR = join(root, "worktrees"); try { // Seed real rows via the default store paths before purging through them. const seededClaim = openClaimLedger(claimDbPath); @@ -701,6 +720,7 @@ describe("runPurge (real, #5564, #6599)", () => { "ranked-candidates": () => join(root, "ranked-candidates.sqlite3"), "replay-snapshot": () => join(root, "replay-snapshot.sqlite3"), "deny-hook-synthesis": () => join(root, "deny-hook-synthesis.sqlite3"), + "worktree-allocator": () => join(root, "worktree-allocator.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; @@ -728,6 +748,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => fakeStore(0), openReplaySnapshotStore: () => fakeStore(0), initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => fakeStore(0), } as never), ).toBe(0); const purged = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -778,6 +799,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => fakeStore(0), openReplaySnapshotStore: () => fakeStore(0), initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => fakeStore(0), } as never), ).toBe(0); const summary = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -824,6 +846,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => fakeStore(0), openReplaySnapshotStore: () => fakeStore(0), initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => fakeStore(0), } as never), ).toBe(0); const summary = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -892,6 +915,7 @@ describe("runPurge (real, #5564, #6599)", () => { initRankedCandidatesStore: () => rankedStore, openReplaySnapshotStore: () => replayStore, initDenyHookSynthesisStore: () => denyStore, + openWorktreeAllocator: () => fakeStore(0), } as never), ).toBe(0); const summary = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -909,4 +933,73 @@ describe("runPurge (real, #5564, #6599)", () => { expect(replayStore.getSnapshot("acme/other", "def456")).not.toBeNull(); expect(denyStore.listProposals("acme/other")).toHaveLength(1); }); + + it("counts (dry-run) and clears (real) only a FREE stale worktree slot, never a live ACTIVE one (#8320)", () => { + const root = tempDir(); + const allocDbPath = join(root, "worktree-allocator.sqlite3"); + + // Seed one ACTIVE slot (a live attempt for acme/other) + one FREE slot left carrying a stale acme/widgets. + // A free slot never carries a real repo in normal operation, so stamp it directly via a second connection. + const seeded = openWorktreeAllocator({ dbPath: allocDbPath, worktreeBaseDir: join(root, "worktrees"), maxConcurrency: 2 }); + seeded.acquire("attempt-live", "acme/other"); + const raw = new DatabaseSync(allocDbPath); + raw.prepare("UPDATE worktree_slots SET repo_full_name = ? WHERE status = 'free'").run("acme/widgets"); + raw.close(); + seeded.close(); + + // DRY RUN: only the free-stale acme/widgets row is counted; the live active acme/other slot never is. + const nonexistent = (name: string) => () => join(root, `${name}.sqlite3`); + const resolveDbPaths = { + "claim-ledger": nonexistent("claim-ledger"), + "event-ledger": nonexistent("event-ledger"), + "governor-ledger": nonexistent("governor-ledger"), + "prediction-ledger": nonexistent("prediction-ledger"), + "portfolio-queue": nonexistent("portfolio-queue"), + "run-state": nonexistent("run-state"), + "contribution-profile-cache": nonexistent("contribution-profile-cache"), + "policy-verdict-cache": nonexistent("policy-verdict-cache"), + "governor-state": nonexistent("governor-state"), + "ranked-candidates": nonexistent("ranked-candidates"), + "replay-snapshot": nonexistent("replay-snapshot"), + "deny-hook-synthesis": nonexistent("deny-hook-synthesis"), + "worktree-allocator": () => allocDbPath, + "attempt-log": nonexistent("attempt-log"), + }; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"], { resolveDbPaths })).toBe(0); + const dryRun = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(dryRun.stores).toContainEqual({ store: "worktree-allocator", wouldPurge: 1 }); + + // REAL PURGE through the store object clears exactly that free-stale row and reports the same count of 1... + log.mockClear(); + const purgeStore = openWorktreeAllocator({ dbPath: allocDbPath, worktreeBaseDir: join(root, "worktrees"), maxConcurrency: 2 }); + closeables.push(purgeStore); + expect( + runPurge(["--repo", "acme/widgets", "--json"], { + openClaimLedger: () => fakeStore(0), + initEventLedger: () => fakeStore(0), + initGovernorLedger: () => fakeStore(0), + initPredictionLedger: () => fakeStore(0), + initPortfolioQueueStore: () => fakeStore(0), + initRunStateStore: () => fakeStore(0), + initContributionProfileCache: () => fakeStore(0), + openGovernorState: () => fakeStore(0), + initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), + openWorktreeAllocator: () => purgeStore, + } as never), + ).toBe(0); + const summary = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(summary.stores).toContainEqual({ store: "worktree-allocator", purged: 1 }); + + // ...leaving the fixed pool intact: the free slot is cleared, the live active acme/other slot untouched. + const slots = purgeStore.listSlots(); + expect(slots).toHaveLength(2); + expect(slots.find((slot) => slot.repoFullName === "acme/widgets")).toBeUndefined(); + const active = slots.find((slot) => slot.attemptId === "attempt-live")!; + expect(active.status).toBe("active"); + expect(active.repoFullName).toBe("acme/other"); + }); }); diff --git a/test/unit/miner-worktree-allocator.test.ts b/test/unit/miner-worktree-allocator.test.ts index a9b1f627ec..fd9f6ea2d7 100644 --- a/test/unit/miner-worktree-allocator.test.ts +++ b/test/unit/miner-worktree-allocator.test.ts @@ -180,3 +180,51 @@ describe("loopover-miner worktree allocator scaffolding (#4298)", () => { expect(cleanupResourceCount()).toBe(0); }); }); + +describe("worktree allocator purgeByRepo (right-to-be-forgotten, #8320)", () => { + // A `free` slot never carries a real repo_full_name in normal operation (release()/reclaimOrphanedAllocations() + // blank it on every free), so seed the stale-row case directly through a second connection to exercise the + // defensive backstop purgeByRepo exists for. + function seedStaleFreeSlot(dbPath: string, slotIndex: number, repoFullName: string): void { + const raw = new DatabaseSync(dbPath); + try { + raw + .prepare("UPDATE worktree_slots SET repo_full_name = ? WHERE slot_index = ? AND status = 'free'") + .run(repoFullName, slotIndex); + } finally { + raw.close(); + } + } + + it("clears a free slot's stale repo_full_name and reports the count, never deleting the slot", () => { + const allocator = tempAllocator({ maxConcurrency: 2 }); + seedStaleFreeSlot(allocator.dbPath, 0, "acme/widgets"); + + expect(allocator.purgeByRepo("acme/widgets")).toBe(1); + + const slots = allocator.listSlots(); + expect(slots).toHaveLength(2); // the fixed pool is intact — no row was deleted + const cleared = slots.find((slot) => slot.slotIndex === 0)!; + expect(cleared.status).toBe("free"); + expect(cleared.repoFullName).toBeNull(); + }); + + it("never touches or counts an ACTIVE slot for the target repo (a live in-flight attempt)", () => { + const allocator = tempAllocator({ maxConcurrency: 2 }); + allocator.acquire("attempt-live", "acme/widgets"); + + expect(allocator.purgeByRepo("acme/widgets")).toBe(0); // the active slot is not purgeable + + const active = allocator.listSlots().find((slot) => slot.attemptId === "attempt-live")!; + expect(active.status).toBe("active"); + expect(active.repoFullName).toBe("acme/widgets"); // the live checkout's repo is left intact + }); + + it("returns 0 when no free slot carries the target repo, leaving other stale rows untouched", () => { + const allocator = tempAllocator({ maxConcurrency: 2 }); + seedStaleFreeSlot(allocator.dbPath, 0, "acme/widgets"); + + expect(allocator.purgeByRepo("acme/other")).toBe(0); + expect(allocator.listSlots().find((slot) => slot.slotIndex === 0)!.repoFullName).toBe("acme/widgets"); + }); +});