Skip to content
Merged
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
11 changes: 10 additions & 1 deletion 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 { 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 All @@ -52,6 +54,7 @@ import {
RANKED_CANDIDATES_PURGE_SPEC,
REPLAY_SNAPSHOT_PURGE_SPEC,
DENY_HOOK_SYNTHESIS_PURGE_SPEC,
WORKTREE_ALLOCATOR_PURGE_SPEC,
countStoreByRepo,
describeError,
} from "./store-maintenance.js";
Expand Down Expand Up @@ -79,7 +82,8 @@ type PurgeOpenerKey =
| "initPolicyVerdictCacheStore"
| "initRankedCandidatesStore"
| "openReplaySnapshotStore"
| "initDenyHookSynthesisStore";
| "initDenyHookSynthesisStore"
| "openWorktreeAllocator";

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

Expand Down Expand Up @@ -124,6 +129,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-allocator (#8320): a fixed-pool store, not an append-only ledger — its own purgeByRepo only
// blanks a `free` row's repo_full_name (never deletes, never touches an `active` row), and the spec's
// extraWhereSql keeps this dry-run count in lockstep with that same restriction.
{ name: "worktree-allocator", optionKey: "openWorktreeAllocator", opener: openWorktreeAllocator, resolveDbPath: resolveWorktreeAllocatorDbPath, spec: WORKTREE_ALLOCATOR_PURGE_SPEC },
];

export type ParsedPurgeArgs = { json: boolean; dryRun: boolean; repoFullName: string } | { error: string };
Expand Down
22 changes: 20 additions & 2 deletions packages/loopover-miner/lib/store-maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ export const EVENT_LEDGER_RETENTION_SPEC: LedgerRetentionSpec = { table: "miner_
export const GOVERNOR_LEDGER_RETENTION_SPEC: LedgerRetentionSpec = { table: "governor_events", timestampColumn: "ts", orderColumn: "id" };
export const PREDICTION_LEDGER_RETENTION_SPEC: LedgerRetentionSpec = { table: "predictions", timestampColumn: "ts", orderColumn: "id" };

export type LedgerPurgeSpec = { table: string; repoColumn: string };
/** `extraWhereSql` is an optional, ANDed, internal-constant-only SQL condition for a store whose real purge
* must not match every row a plain `repoColumn = ?` would — e.g. a fixed-pool slot table where only a `free`
* row is safe to clear (#8320). Only `countStoreByRepo` (the read-only dry-run counter) honors it; the
* real-delete path, `purgeStoreByRepo`, is never used for such a store (it does an unconditional `DELETE`,
* wrong for a row that must be preserved and merely blanked) — its own custom `purgeByRepo` method is used
* instead, and `--dry-run` must count using the identical condition so its preview never overstates what a
* real purge would remove. */
export type LedgerPurgeSpec = { table: string; repoColumn: string; extraWhereSql?: string };

/** Fixed purge specs (#5564, #6599) for the six stores whose rows are directly scoped by a `repoColumn`. Same
* internal-constant-only discipline as the retention specs above. `attempt-log.js` is deliberately absent: its
Expand Down Expand Up @@ -70,6 +77,14 @@ export const RANKED_CANDIDATES_PURGE_SPEC: LedgerPurgeSpec = { table: "miner_ran
export const REPLAY_SNAPSHOT_PURGE_SPEC: LedgerPurgeSpec = { table: "replay_snapshots", repoColumn: "repo_full_name" };
export const DENY_HOOK_SYNTHESIS_PURGE_SPEC: LedgerPurgeSpec = { table: "deny_rule_proposals", repoColumn: "repo_full_name" };

/** worktree-allocator's `worktree_slots` (#8320), the last repo-scoped store the #5564/#7091/#6987/#8009 sweeps
* missed. Unlike every spec above, this table is a fixed pool — `slot_index` is the primary key and every slot
* 0..maxConcurrency-1 always exists, so a purge must never delete a row, and must never touch a `status =
* 'active'` row (a live, in-flight attempt's real on-disk worktree checkout). `extraWhereSql` restricts even
* the read-only dry-run count to the same `status = 'free'` condition the real purge (worktree-allocator.js's
* own hand-written `purgeByRepo`, not this file's generic `purgeStoreByRepo`) enforces. */
export const WORKTREE_ALLOCATOR_PURGE_SPEC: LedgerPurgeSpec = { table: "worktree_slots", repoColumn: "repo_full_name", extraWhereSql: "status = 'free'" };

export type StoreIntegrityResult = { name: string; ok: boolean; detail: string };
export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number };

Expand Down Expand Up @@ -205,6 +220,9 @@ export function countStoreByRepo(db: DatabaseSync, spec: LedgerPurgeSpec, repoFu
for (const identifier of [spec.table, spec.repoColumn]) {
if (!SQL_IDENTIFIER.test(identifier)) throw new Error(`unsafe SQL identifier: ${identifier}`);
}
const row = db.prepare(`SELECT COUNT(*) AS count FROM ${spec.table} WHERE ${spec.repoColumn} = ?`).get(repoFullName);
// extraWhereSql is only ever one of this file's own internal constants (never caller/user text), so it is
// ANDed in verbatim rather than parsed as an identifier — see LedgerPurgeSpec's doc comment (#8320).
const extraWhere = spec.extraWhereSql ? ` AND (${spec.extraWhereSql})` : "";
const row = db.prepare(`SELECT COUNT(*) AS count FROM ${spec.table} WHERE ${spec.repoColumn} = ?${extraWhere}`).get(repoFullName);
return Number(row?.count);
}
18 changes: 18 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,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: right-to-be-forgotten for a fixed-pool store. Never DELETE (every slot_index 0..maxConcurrency-1
// must always exist) and never touch an `active` row — its repo_full_name reflects a live, currently-running
// attempt's real on-disk worktree checkout, and force-clearing it while active would desync the allocator
// from that live checkout. Only a `free` row can ever carry a stale repo_full_name in practice (release()/
// reclaimOrphanedAllocations() already blank it on every path that frees a slot), so this is expected to
// affect 0 rows in the overwhelming majority of real calls — it exists as a defensive backstop for a row that
// predates this fix or was left stale by an unexpected crash path, not as the primary cleanup mechanism.
const purgeFreeSlotsByRepo = 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 +371,11 @@ export function openWorktreeAllocator(options: {
listSlots() {
return (listSlots.all() as WorktreeSlotRow[]).map(rowToAllocation);
},
purgeByRepo(repoFullName) {
const normalized = normalizeRepoFullName(repoFullName);
const info = purgeFreeSlotsByRepo.run(normalized);
return Number(info.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 @@ -1508,6 +1508,7 @@ describe("runAttempt (#5132)", () => {
},
release: vi.fn(),
listSlots: () => [],
purgeByRepo: vi.fn(() => 0),
close: vi.fn(),
}),
openClaimLedger: () => claimLedger,
Expand Down
Loading