diff --git a/packages/loopover-miner/lib/purge-cli.ts b/packages/loopover-miner/lib/purge-cli.ts index d61b8ee80..218d1ea21 100644 --- a/packages/loopover-miner/lib/purge-cli.ts +++ b/packages/loopover-miner/lib/purge-cli.ts @@ -37,6 +37,12 @@ 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, + countPurgeableWorktreeSlotsByRepo, +} from "./worktree-allocator.js"; +import type { WorktreeAllocator } from "./worktree-allocator.js"; import { resolveAttemptLogDbPath } from "./attempt-log.js"; import { CLAIM_LEDGER_PURGE_SPEC, @@ -79,7 +85,8 @@ type PurgeOpenerKey = | "initPolicyVerdictCacheStore" | "initRankedCandidatesStore" | "openReplaySnapshotStore" - | "initDenyHookSynthesisStore"; + | "initDenyHookSynthesisStore" + | "openWorktreeAllocator"; export type PurgeCliOptions = { openClaimLedger?: () => ClaimLedger; @@ -94,6 +101,7 @@ export type PurgeCliOptions = { initRankedCandidatesStore?: () => RankedCandidatesStore; openReplaySnapshotStore?: () => ReplaySnapshotStore; initDenyHookSynthesisStore?: () => DenyHookSynthesisStore; + openWorktreeAllocator?: () => WorktreeAllocator; resolveDbPaths?: Record string>; }; @@ -104,6 +112,9 @@ type PurgeTarget = { resolveDbPath: () => string; spec?: LedgerPurgeSpec; specs?: LedgerPurgeSpec[]; + // A store whose --dry-run count needs custom, non-generic logic (worktree-allocator's status-aware count, + // #8320) declares it here INSTEAD of a spec/specs, exactly as its purge logic lives on the store object. + countByRepo?: (db: DatabaseSync, repoFullName: string) => number; }; const REAL_PURGE_TARGETS: PurgeTarget[] = [ @@ -124,6 +135,12 @@ 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 purgeStoreByRepo would corrupt the pool (#8320). Its purge lives on the store + // object (purgeByRepo clears only FREE, never active, slots), and its dry-run count uses a dedicated status- + // aware counter (countByRepo) instead of a spec/specs — exactly mirroring how governor-state's entry carries + // `specs` rather than a single `spec` for its own non-uniform case. + { name: "worktree-allocator", optionKey: "openWorktreeAllocator", opener: openWorktreeAllocator, resolveDbPath: resolveWorktreeAllocatorDbPath, countByRepo: countPurgeableWorktreeSlotsByRepo }, ]; export type ParsedPurgeArgs = { json: boolean; dryRun: boolean; repoFullName: string } | { error: string }; @@ -216,13 +233,19 @@ 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 either scopes one table (`spec`) or -- for governor-state -- several in one file (`specs`), summed + // against the single read-only handle; or, when its purge can't be a generic repoColumn DELETE, brings its + // own status-aware counter (`countByRepo`, worktree-allocator #8320). Whichever it is, the preview must match + // exactly what a real purge removes. Every entry declares exactly one of the three, so `target.spec!` is only + // dereferenced on the spec branch, where it is always set. try { const wouldPurge = countExistingRows(dbPath, (db) => - specs.reduce((sum, spec) => sum + countStoreByRepo(db, spec, parsed.repoFullName), 0), + target.countByRepo + ? target.countByRepo(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 ac7cd40df..bf9c38c18 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; }; @@ -252,6 +253,32 @@ function reclaimOrphanedAllocations(db: DatabaseSync, nowMs: number, maxLeaseMs: } } +// Right-to-be-forgotten purge (#8320). `worktree_slots` is a FIXED pool of pre-allocated slot rows (every index +// 0..maxConcurrency-1 always exists), NOT an append-only ledger, so the generic DELETE-based `purgeStoreByRepo` +// would shrink the pool and break ensureSlots/selectFreeSlot's invariant. Instead, purge-by-repo clears the repo +// only from slots that are already `free` — mirroring release()/reclaimOrphanedAllocations()'s own blanking of +// repo_full_name/attempt_id/owner_pid/owner_host/allocated_at. An `active` slot is NEVER matched: its +// repo_full_name reflects a live, in-flight attempt's real worktree checkout on disk, and clearing it while +// active would desync the allocator from that checkout. This match condition is shared verbatim by the real +// UPDATE (below) and the read-only --dry-run counter (countPurgeableWorktreeSlotsByRepo) so the two can never +// diverge. It is expected to match 0 rows on the overwhelming majority of real calls, by design: every normal +// release/reclaim path already blanks these fields on free, so only a row predating this fix or stranded by an +// unexpected crash path can ever match — the clear is a defensive backstop, not a routine deletion. +const PURGEABLE_FREE_SLOT_MATCH = "status = 'free' AND repo_full_name = ?"; + +/** + * Read-only count of the rows `purgeByRepo` would clear for one repo (#8320) — the `--dry-run` counterpart used + * by purge-cli.js against a driver-enforced read-only handle. Uses the exact same `PURGEABLE_FREE_SLOT_MATCH` + * condition the real purge does, so an `active` slot is never reported as purgeable in dry-run either. Mirrors + * store-maintenance.js's `countStoreByRepo` for the generic repoColumn stores. + */ +export function countPurgeableWorktreeSlotsByRepo(db: DatabaseSync, repoFullName: string): number { + const countRow = db + .prepare(`SELECT COUNT(*) AS count FROM worktree_slots WHERE ${PURGEABLE_FREE_SLOT_MATCH}`) + .get(repoFullName) as CountRow; + return countRow.count; +} + /** * Opens the local worktree allocator store. On startup reclaims orphaned active slots — any slot past its * `maxLeaseMs` age (the container-agnostic guarantee for fleet mode's shared store), plus, as a same-host fast @@ -304,6 +331,11 @@ 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", ); + 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 ${PURGEABLE_FREE_SLOT_MATCH} + `); const allocator: WorktreeAllocator = { dbPath: resolvedPath, @@ -358,6 +390,14 @@ export function openWorktreeAllocator(options: { listSlots() { return (listSlots.all() as WorktreeSlotRow[]).map(rowToAllocation); }, + purgeByRepo(repoFullName) { + // Right-to-be-forgotten sweep (#8320): clear the repo only from FREE slots (see PURGEABLE_FREE_SLOT_MATCH). + // Never deletes a pooled slot row and never touches an `active` slot's live in-flight checkout. Returns the + // number of rows cleared — expected to be 0 on the overwhelming majority of real calls, by design, since + // every normal release/reclaim path already blanks these fields on free. + const normalizedRepo = normalizeRepoFullName(repoFullName); + return Number(purgeFreeByRepo.run(normalizedRepo).changes); + }, close() { db.close(); }, diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index e7f3231cd..368207558 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: () => 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 1355f0b8a..ce57eba7b 100644 --- a/test/unit/miner-purge-cli.test.ts +++ b/test/unit/miner-purge-cli.test.ts @@ -1,6 +1,7 @@ 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 { openClaimLedger, closeDefaultClaimLedger } from "../../packages/loopover-miner/lib/claim-ledger.js"; import { initEventLedger, closeDefaultEventLedger } from "../../packages/loopover-miner/lib/event-ledger.js"; @@ -21,6 +22,7 @@ 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 { ATTEMPT_LOG_NOT_PURGEABLE_NOTE, @@ -222,6 +224,8 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "ranked-candidates": () => rankedCandidatesDbPath, "replay-snapshot": () => replaySnapshotDbPath, "deny-hook-synthesis": () => denyHookSynthesisDbPath, + // worktree-allocator file is never created here — dry-run against it must count 0 without writing. + "worktree-allocator": () => join(root, "worktree-allocator.sqlite3"), "attempt-log": () => attemptLogDbPath, }; @@ -245,6 +249,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 +285,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 +363,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 +408,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 +423,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 +471,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 +494,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 +539,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 +579,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 +603,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 +629,10 @@ 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, + // worktree-allocator opens a REAL store here (no injection), so keep it hermetic: both its DB path and + // its on-disk worktree base dir must resolve under the temp root, never the real ~/.config tree. + 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 +649,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 +721,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 +749,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 +800,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 +847,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 +916,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 +934,73 @@ describe("runPurge (real, #5564, #6599)", () => { expect(replayStore.getSnapshot("acme/other", "def456")).not.toBeNull(); expect(denyStore.listProposals("acme/other")).toHaveLength(1); }); + + it("REGRESSION (#8320): clears a stale FREE worktree slot for the repo, never an ACTIVE one, and --dry-run matches the real count", () => { + const root = tempDir(); + const worktreeAllocatorDbPath = join(root, "worktree-allocator.sqlite3"); + const worktreeBaseDir = join(root, "worktrees"); + + // Seed: slot 0 ACTIVE against the target repo (a live in-flight attempt), slot 1 a STALE free row still + // carrying the repo (bypassing the normal acquire/release path, which never leaves one). Only slot 1 counts. + const seeded = openWorktreeAllocator({ dbPath: worktreeAllocatorDbPath, worktreeBaseDir, maxConcurrency: 3 }); + seeded.acquire("attempt-live", "acme/widgets"); + const seedDb = new DatabaseSync(worktreeAllocatorDbPath); + seedDb.exec("UPDATE worktree_slots SET repo_full_name = 'acme/widgets' WHERE slot_index = 1"); + seedDb.close(); + seeded.close(); + + // --dry-run counts ONLY the free-stale row (1), never the active one — no writes. + const resolveDbPaths = { + "claim-ledger": () => join(root, "claim-ledger.sqlite3"), + "event-ledger": () => join(root, "event-ledger.sqlite3"), + "governor-ledger": () => join(root, "governor-ledger.sqlite3"), + "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), + "portfolio-queue": () => join(root, "portfolio-queue.sqlite3"), + "run-state": () => join(root, "run-state.sqlite3"), + "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), + "policy-verdict-cache": () => join(root, "policy-verdict-cache.sqlite3"), + "governor-state": () => join(root, "governor-state.sqlite3"), + "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": () => worktreeAllocatorDbPath, + "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 dryRun = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(dryRun.stores).toContainEqual({ store: "worktree-allocator", wouldPurge: 1 }); + + // The real purge removes exactly that free-stale row and leaves the active slot fully intact. + log.mockClear(); + const worktreeStore = openWorktreeAllocator({ dbPath: worktreeAllocatorDbPath, worktreeBaseDir, maxConcurrency: 3 }); + closeables.push(worktreeStore); + 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: () => worktreeStore, + } as never), + ).toBe(0); + const summary = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(summary.stores).toContainEqual({ store: "worktree-allocator", purged: 1 }); + + const slots = worktreeStore.listSlots(); + const activeSlot = slots.find((slot) => slot.slotIndex === 0); + expect(activeSlot?.status).toBe("active"); + expect(activeSlot?.repoFullName).toBe("acme/widgets"); + expect(slots.find((slot) => slot.slotIndex === 1)?.repoFullName).toBeNull(); + // The fixed pool is intact — no slot row was deleted. + expect(slots).toHaveLength(3); + }); }); diff --git a/test/unit/miner-worktree-allocator.test.ts b/test/unit/miner-worktree-allocator.test.ts index a9b1f627e..cd5a12e23 100644 --- a/test/unit/miner-worktree-allocator.test.ts +++ b/test/unit/miner-worktree-allocator.test.ts @@ -180,3 +180,41 @@ describe("loopover-miner worktree allocator scaffolding (#4298)", () => { expect(cleanupResourceCount()).toBe(0); }); }); + +describe("worktree allocator right-to-be-forgotten purge (#8320)", () => { + it("clears only a stale FREE slot for the repo, leaves an ACTIVE slot fully untouched, and counts one", () => { + const allocator = tempAllocator({ maxConcurrency: 3 }); + // slot 0: an ACTIVE attempt against the target repo — a live in-flight checkout that must never be touched. + const active = allocator.acquire("attempt-live", "acme/widgets"); + // slot 1: seed a STALE free row carrying the repo directly (normal release/reclaim always blanks these, so + // bypass them). slot 2 stays clean/free with no repo. Only slot 1 must be cleared and counted. + const seedDb = new DatabaseSync(allocator.dbPath); + seedDb.exec("UPDATE worktree_slots SET repo_full_name = 'acme/widgets' WHERE slot_index = 1"); + seedDb.close(); + + expect(allocator.purgeByRepo("acme/widgets")).toBe(1); + + const slots = allocator.listSlots(); + const activeSlot = slots.find((slot) => slot.slotIndex === active.slotIndex); + expect(activeSlot?.status).toBe("active"); + expect(activeSlot?.repoFullName).toBe("acme/widgets"); + expect(activeSlot?.attemptId).toBe("attempt-live"); + const clearedSlot = slots.find((slot) => slot.slotIndex === 1); + expect(clearedSlot?.status).toBe("free"); + expect(clearedSlot?.repoFullName).toBeNull(); + expect(clearedSlot?.attemptId).toBeNull(); + // The pool is intact — no row was deleted, every slot index still exists. + expect(slots).toHaveLength(3); + }); + + it("returns 0 when no FREE slot carries the repo — an ACTIVE row for it is never cleared or counted", () => { + const allocator = tempAllocator({ maxConcurrency: 1 }); + allocator.acquire("attempt-live", "acme/widgets"); + // A different repo has nothing to forget; the target repo's only row is active, so it too must be left alone. + expect(allocator.purgeByRepo("acme/other")).toBe(0); + expect(allocator.purgeByRepo("acme/widgets")).toBe(0); + const slot = allocator.listSlots()[0]; + expect(slot?.status).toBe("active"); + expect(slot?.repoFullName).toBe("acme/widgets"); + }); +});