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
35 changes: 29 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,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,
Expand Down Expand Up @@ -79,7 +85,8 @@ type PurgeOpenerKey =
| "initPolicyVerdictCacheStore"
| "initRankedCandidatesStore"
| "openReplaySnapshotStore"
| "initDenyHookSynthesisStore";
| "initDenyHookSynthesisStore"
| "openWorktreeAllocator";

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

Expand All @@ -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[] = [
Expand All @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 40 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 @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
},
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: () => 0,
close: vi.fn(),
}),
openClaimLedger: () => claimLedger,
Expand Down
Loading