From 2d3c71b29cb711e773850944585d8575842d7be3 Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Sat, 25 Jul 2026 00:18:53 +0400 Subject: [PATCH] fix(miner): register worktree-allocator in purge-cli's right-to-be-forgotten sweep (#8320) worktree_slots carries repo_full_name like every other repo-scoped store, but was absent from purge-cli's REAL_PURGE_TARGETS. Adds WorktreeAllocator.purgeByRepo, a hand-written UPDATE (not the generic purgeStoreByRepo DELETE, which would shrink the fixed slot pool) that only ever clears a free slot's stale repo_full_name -- an active slot's live worktree checkout is never touched. Registers the store in REAL_PURGE_TARGETS with its own dry-run counting query matching the same free-only match condition. --- packages/loopover-miner/lib/purge-cli.ts | 45 ++++++++++++---- .../loopover-miner/lib/worktree-allocator.ts | 18 +++++++ test/unit/miner-attempt-cli.test.ts | 1 + test/unit/miner-purge-cli.test.ts | 51 +++++++++++++++++-- test/unit/miner-worktree-allocator.test.ts | 33 ++++++++++++ 5 files changed, 135 insertions(+), 13 deletions(-) diff --git a/packages/loopover-miner/lib/purge-cli.ts b/packages/loopover-miner/lib/purge-cli.ts index d61b8ee805..74320066d2 100644 --- a/packages/loopover-miner/lib/purge-cli.ts +++ b/packages/loopover-miner/lib/purge-cli.ts @@ -2,8 +2,10 @@ // ledgers. Deletes every row for one repo from the stores that have a real `repoColumn` (claim-ledger, // event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache, // governor-state's two repo-scoped tables — #7091 — plus policy-verdict-cache — #6987 — and ranked-candidates, -// replay-snapshot, and deny-hook-synthesis — #8009), via each store's own `purgeByRepo` method (which reuses -// `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`). +// replay-snapshot, and deny-hook-synthesis — #8009 — and worktree-allocator's fixed-pool `worktree_slots` — #8320), +// via each store's own `purgeByRepo` method. Every store but worktree-allocator reuses `store-maintenance.js`'s +// shared, identifier-guarded `purgeStoreByRepo` (a hard DELETE); worktree-allocator hand-rolls an UPDATE instead, +// since deleting a fixed-pool slot row would break its own invariant that every slot index always exists. // `attempt-log.js` is deliberately reported as not-purgeable rather than silently skipped or approximated: its // payload is a free-form `Record` with no dedicated repo column, so a precise per-repo match // isn't possible there without risking false matches -- see store-maintenance.js's own purge-spec doc comment. @@ -37,6 +39,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 { 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 +83,8 @@ type PurgeOpenerKey = | "initPolicyVerdictCacheStore" | "initRankedCandidatesStore" | "openReplaySnapshotStore" - | "initDenyHookSynthesisStore"; + | "initDenyHookSynthesisStore" + | "openWorktreeAllocator"; export type PurgeCliOptions = { openClaimLedger?: () => ClaimLedger; @@ -94,6 +99,7 @@ export type PurgeCliOptions = { initRankedCandidatesStore?: () => RankedCandidatesStore; openReplaySnapshotStore?: () => ReplaySnapshotStore; initDenyHookSynthesisStore?: () => DenyHookSynthesisStore; + openWorktreeAllocator?: () => WorktreeAllocator; resolveDbPaths?: Record string>; }; @@ -104,8 +110,21 @@ type PurgeTarget = { resolveDbPath: () => string; spec?: LedgerPurgeSpec; specs?: LedgerPurgeSpec[]; + // worktree-allocator's purge logic lives on the store object itself (a fixed-pool UPDATE, never a DELETE), + // not a generic LedgerPurgeSpec -- so its dry-run preview needs its own read-only counting query instead of + // the shared countStoreByRepo (#8320). + countDryRun?: (db: DatabaseSync, repoFullName: string) => number; }; +// Mirrors worktree-allocator.ts's own purgeFreeByRepo match condition exactly (status = 'free' AND +// repo_full_name = ?) -- an active slot's row must never be counted as purgeable in dry-run either. +function countWorktreeAllocatorFreeByRepo(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 { count: number } | undefined; + return Number(row?.count); +} + const REAL_PURGE_TARGETS: PurgeTarget[] = [ { name: "claim-ledger", optionKey: "openClaimLedger", opener: openClaimLedger, resolveDbPath: resolveClaimLedgerDbPath, spec: CLAIM_LEDGER_PURGE_SPEC }, { name: "event-ledger", optionKey: "initEventLedger", opener: initEventLedger, resolveDbPath: resolveEventLedgerDbPath, spec: EVENT_LEDGER_PURGE_SPEC }, @@ -124,6 +143,10 @@ 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_slots is a fixed pool of pre-allocated rows, not an append-only ledger -- no spec/specs field here; + // the store's own purgeByRepo (an UPDATE that only ever touches a free slot) and countDryRun above are its + // non-uniform equivalent, the same shape governor-state's `specs` field covers for its own non-uniform case. + { name: "worktree-allocator", optionKey: "openWorktreeAllocator", opener: openWorktreeAllocator, resolveDbPath: resolveWorktreeAllocatorDbPath, countDryRun: countWorktreeAllocatorFreeByRepo }, ]; export type ParsedPurgeArgs = { json: boolean; dryRun: boolean; repoFullName: string } | { error: string }; @@ -216,14 +239,16 @@ 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!]; try { - const wouldPurge = countExistingRows(dbPath, (db) => - specs.reduce((sum, spec) => sum + countStoreByRepo(db, spec, parsed.repoFullName), 0), - ); + // worktree-allocator's non-uniform match condition (free slots only) has its own counting query + // (#8320) instead of a generic LedgerPurgeSpec; every other target scopes one table (`spec`) or -- + // for governor-state -- several in one file (`specs`), summed per-table against the single read-only + // handle so the preview matches what a real purge removes. + const wouldPurge = target.countDryRun + ? countExistingRows(dbPath, (db) => target.countDryRun!(db, parsed.repoFullName)) + : countExistingRows(dbPath, (db) => + (target.specs ?? [target.spec!]).reduce((sum, spec) => sum + countStoreByRepo(db, spec, parsed.repoFullName), 0), + ); return { store: target.name, wouldPurge }; } catch (error) { return { store: target.name, wouldPurge: null, error: describeError(error) }; diff --git a/packages/loopover-miner/lib/worktree-allocator.ts b/packages/loopover-miner/lib/worktree-allocator.ts index ac7cd40dfb..d2501fca17 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,18 @@ 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", ); + // #8320: a defensive backstop, not the normal path -- release()/reclaimOrphanedAllocations() already blank + // repo_full_name on every path that frees a slot, so this is expected to match 0 rows in the overwhelming + // majority of real calls. Deliberately an UPDATE, never a DELETE: worktree_slots is a fixed pool of + // maxConcurrency pre-allocated rows (slot_index is the primary key and every index must always exist), so + // removing a row would break ensureSlots'/selectFreeSlot's invariant. The `status = 'free'` guard is load- + // bearing -- an `active` row's repo_full_name reflects a live, currently-running attempt's real worktree + // checkout on disk, and force-clearing it would desync the allocator from that checkout. + 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 +371,11 @@ export function openWorktreeAllocator(options: { listSlots() { return (listSlots.all() as WorktreeSlotRow[]).map(rowToAllocation); }, + purgeByRepo(repoFullName) { + const normalizedRepo = normalizeRepoFullName(repoFullName); + const info = purgeFreeByRepo.run(normalizedRepo); + return Number(info.changes); + }, close() { db.close(); }, diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index e7f3231cd2..e0da225dd5 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(), 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..c088bf6b31 100644 --- a/test/unit/miner-purge-cli.test.ts +++ b/test/unit/miner-purge-cli.test.ts @@ -21,7 +21,9 @@ import { openGovernorState } from "../../packages/loopover-miner/lib/governor-st import { initRankedCandidatesStore } from "../../packages/loopover-miner/lib/ranked-candidates.js"; import { openReplaySnapshotStore } from "../../packages/loopover-miner/lib/replay-snapshot.js"; import { initDenyHookSynthesisStore } from "../../packages/loopover-miner/lib/deny-hook-synthesis.js"; +import { openWorktreeAllocator } from "../../packages/loopover-miner/lib/worktree-allocator.js"; import { emptyContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; +import { DatabaseSync } from "node:sqlite"; import { ATTEMPT_LOG_NOT_PURGEABLE_NOTE, parsePurgeArgs, @@ -88,7 +90,7 @@ describe("parsePurgeArgs (#5564)", () => { }); describe("runPurge --dry-run (#5564, #6599)", () => { - it("counts matching rows across the twelve real stores without writing anything, and reports attempt-log as not-purgeable", async () => { + it("counts matching rows across the thirteen real stores without writing anything, and reports attempt-log as not-purgeable", async () => { const root = tempDir(); const claimDbPath = join(root, "claim-ledger.sqlite3"); const eventDbPath = join(root, "event-ledger.sqlite3"); @@ -102,6 +104,7 @@ describe("runPurge --dry-run (#5564, #6599)", () => { const rankedCandidatesDbPath = join(root, "ranked-candidates.sqlite3"); const replaySnapshotDbPath = join(root, "replay-snapshot.sqlite3"); const denyHookSynthesisDbPath = join(root, "deny-hook-synthesis.sqlite3"); + const worktreeAllocatorDbPath = join(root, "worktree-allocator.sqlite3"); const attemptLogDbPath = join(root, "attempt-log.sqlite3"); // never created — dry run must not touch it const claimLedger = openClaimLedger(claimDbPath); @@ -209,6 +212,20 @@ describe("runPurge --dry-run (#5564, #6599)", () => { ]); denyHookSynthesis.close(); + // worktree-allocator only counts a FREE slot's stale repo_full_name (#8320) — normal release() already + // blanks it, so seed one directly (bypassing acquire/release) the same way miner-worktree-allocator.test.ts + // does for its own purgeByRepo suite. Two free slots, one per repo, only widgets' must count. + const worktreeAllocator = openWorktreeAllocator({ + dbPath: worktreeAllocatorDbPath, + worktreeBaseDir: join(root, "worktrees"), + maxConcurrency: 2, + }); + worktreeAllocator.close(); + const worktreeDb = new DatabaseSync(worktreeAllocatorDbPath); + worktreeDb.prepare("UPDATE worktree_slots SET repo_full_name = ? WHERE slot_index = 0").run("acme/widgets"); + worktreeDb.prepare("UPDATE worktree_slots SET repo_full_name = ? WHERE slot_index = 1").run("acme/other"); + worktreeDb.close(); + const resolveDbPaths = { "claim-ledger": () => claimDbPath, "event-ledger": () => eventDbPath, @@ -222,6 +239,7 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "ranked-candidates": () => rankedCandidatesDbPath, "replay-snapshot": () => replaySnapshotDbPath, "deny-hook-synthesis": () => denyHookSynthesisDbPath, + "worktree-allocator": () => worktreeAllocatorDbPath, "attempt-log": () => attemptLogDbPath, }; @@ -245,6 +263,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: 1 }, ], attemptLogNote: ATTEMPT_LOG_NOT_PURGEABLE_NOTE, attemptLogTotalRows: 0, @@ -254,6 +273,12 @@ describe("runPurge --dry-run (#5564, #6599)", () => { const reopenedClaim = openClaimLedger(claimDbPath); closeables.push(reopenedClaim); expect(reopenedClaim.listClaims()).toHaveLength(3); + const reopenedWorktreeDb = new DatabaseSync(worktreeAllocatorDbPath, { readOnly: true }); + const stillStaleSlot = reopenedWorktreeDb + .prepare("SELECT repo_full_name FROM worktree_slots WHERE slot_index = 0") + .get() as { repo_full_name: string | null }; + reopenedWorktreeDb.close(); + expect(stillStaleSlot.repo_full_name).toBe("acme/widgets"); log.mockClear(); expect(runPurge(["--repo", "acme/widgets", "--dry-run"], { resolveDbPaths })).toBe(0); @@ -262,6 +287,7 @@ describe("runPurge --dry-run (#5564, #6599)", () => { expect(text).toContain("claim-ledger=2"); expect(text).toContain("portfolio-queue=2"); expect(text).toContain("run-state=1"); + expect(text).toContain("worktree-allocator=1"); expect(text).toContain(ATTEMPT_LOG_NOT_PURGEABLE_NOTE); }); @@ -280,12 +306,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)) { @@ -326,6 +353,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": () => attemptLogDbPath, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -357,6 +385,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 +430,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 +445,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 +493,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 +516,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 +561,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 +601,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 +625,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 +651,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 +669,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 +741,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 +769,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 +820,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 +867,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 +936,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])); diff --git a/test/unit/miner-worktree-allocator.test.ts b/test/unit/miner-worktree-allocator.test.ts index a9b1f627ec..0e19776add 100644 --- a/test/unit/miner-worktree-allocator.test.ts +++ b/test/unit/miner-worktree-allocator.test.ts @@ -179,4 +179,37 @@ describe("loopover-miner worktree allocator scaffolding (#4298)", () => { closeAllCleanupResources(); // what installCliSignalHandlers invokes on SIGINT/SIGTERM expect(cleanupResourceCount()).toBe(0); }); + + describe("purgeByRepo (#8320)", () => { + it("clears a free slot carrying a stale repo_full_name and counts it", () => { + const allocator = tempAllocator({ maxConcurrency: 2 }); + // Normal release()/reclaimOrphanedAllocations() paths already blank repo_full_name on free, so seed a + // stale value directly (bypassing acquire/release) to exercise the defensive-backstop path. + const db = new DatabaseSync(allocator.dbPath); + db.prepare("UPDATE worktree_slots SET repo_full_name = ? WHERE slot_index = 0").run("acme/widgets"); + db.close(); + + expect(allocator.purgeByRepo("acme/widgets")).toBe(1); + const slot = allocator.listSlots().find((entry) => entry.slotIndex === 0); + expect(slot?.repoFullName).toBeNull(); + expect(slot?.status).toBe("free"); + }); + + it("never touches an active slot for the target repo", () => { + const allocator = tempAllocator({ maxConcurrency: 1 }); + const active = allocator.acquire("attempt-a", "acme/widgets"); + + expect(allocator.purgeByRepo("acme/widgets")).toBe(0); + const slot = allocator.listSlots().find((entry) => entry.slotIndex === active.slotIndex); + expect(slot?.status).toBe("active"); + expect(slot?.repoFullName).toBe("acme/widgets"); + expect(slot?.attemptId).toBe("attempt-a"); + }); + + it("returns 0 when no slot matches the repo", () => { + const allocator = tempAllocator({ maxConcurrency: 1 }); + allocator.acquire("attempt-a", "acme/widgets"); + expect(allocator.purgeByRepo("acme/other")).toBe(0); + }); + }); });