Skip to content
Closed
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
26 changes: 20 additions & 6 deletions packages/loopover-miner/lib/purge-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,7 +81,8 @@ type PurgeOpenerKey =
| "initPolicyVerdictCacheStore"
| "initRankedCandidatesStore"
| "openReplaySnapshotStore"
| "initDenyHookSynthesisStore";
| "initDenyHookSynthesisStore"
| "openWorktreeAllocator";

export type PurgeCliOptions = {
openClaimLedger?: () => ClaimLedger;
Expand All @@ -94,6 +97,7 @@ export type PurgeCliOptions = {
initRankedCandidatesStore?: () => RankedCandidatesStore;
openReplaySnapshotStore?: () => ReplaySnapshotStore;
initDenyHookSynthesisStore?: () => DenyHookSynthesisStore;
openWorktreeAllocator?: () => WorktreeAllocator;
resolveDbPaths?: Record<string, () => string>;
};

Expand All @@ -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[] = [
Expand All @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions packages/loopover-miner/lib/worktree-allocator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
},
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions test/unit/miner-attempt-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1372,6 +1372,7 @@ describe("runAttempt (#5132)", () => {
},
release: vi.fn(),
listSlots: () => [],
purgeByRepo: vi.fn(() => 0),
close: vi.fn(),
}),
openClaimLedger: () => claimLedger,
Expand Down
Loading