diff --git a/packages/loopover-miner/lib/worktree-allocator.js b/packages/loopover-miner/lib/worktree-allocator.js index ade02d63cd..b0e34e147b 100644 --- a/packages/loopover-miner/lib/worktree-allocator.js +++ b/packages/loopover-miner/lib/worktree-allocator.js @@ -1,11 +1,12 @@ -import { chmodSync, mkdirSync } from "node:fs"; +import { mkdirSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { join } from "node:path"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; // Git-worktree-per-attempt allocator (#4297): durable local bookkeeping for which worktree paths are -// allocated to which fleet attempts. Mirrors the package's existing local-store pattern (run-state.js, -// claim-ledger.js, portfolio-queue.js) — plain JS + node:sqlite, never phones home. +// allocated to which fleet attempts. Opens its SQLite handle through local-store.js's openLocalStoreDb (like +// run-state.js, claim-ledger.js, portfolio-queue.js), so the handle is registered for crash-safe cleanup +// (#4826) — a SIGINT/SIGTERM/crash mid-write is flushed/closed cleanly. Plain JS + node:sqlite, never phones home. const defaultDbFileName = "worktree-allocator.sqlite3"; const defaultWorktreeDirName = "worktrees"; @@ -13,20 +14,7 @@ const defaultMaxConcurrency = 2; let defaultWorktreeAllocator = null; export function resolveWorktreeAllocatorDbPath(env = process.env) { - const explicitPath = typeof env.LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB === "string" - ? env.LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB.trim() - : ""; - if (explicitPath) return explicitPath; - - const explicitConfigDir = typeof env.LOOPOVER_MINER_CONFIG_DIR === "string" - ? env.LOOPOVER_MINER_CONFIG_DIR.trim() - : ""; - if (explicitConfigDir) return join(explicitConfigDir, defaultDbFileName); - - const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() - ? env.XDG_CONFIG_HOME.trim() - : join(homedir(), ".config"); - return join(configHome, "loopover-miner", defaultDbFileName); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB", env); } export function resolveWorktreeBaseDir(env = process.env) { @@ -47,9 +35,7 @@ export function resolveWorktreeBaseDir(env = process.env) { } function normalizeDbPath(dbPath) { - const path = (dbPath ?? resolveWorktreeAllocatorDbPath()).trim(); - if (!path) throw new Error("invalid_worktree_allocator_db_path"); - return path; + return normalizeLocalStoreDbPath(dbPath, resolveWorktreeAllocatorDbPath(), "invalid_worktree_allocator_db_path"); } function normalizeWorktreeBaseDir(worktreeBaseDir) { @@ -154,10 +140,7 @@ export function openWorktreeAllocator(options = {}) { const maxConcurrency = normalizeMaxConcurrency(options.maxConcurrency); const processPid = Number.isInteger(options.processPid) ? options.processPid : process.pid; - mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); - const db = new DatabaseSync(resolvedPath); - chmodSync(resolvedPath, 0o600); - db.exec("PRAGMA busy_timeout = 5000"); + const db = openLocalStoreDb(resolvedPath); ensureSlotTable(db); ensureSlots(db, worktreeBaseDir, maxConcurrency); reclaimOrphanedAllocations(db); diff --git a/test/unit/miner-worktree-allocator.test.ts b/test/unit/miner-worktree-allocator.test.ts index 2d62e06f2c..da0dbb3768 100644 --- a/test/unit/miner-worktree-allocator.test.ts +++ b/test/unit/miner-worktree-allocator.test.ts @@ -9,6 +9,7 @@ import { resolveWorktreeAllocatorDbPath, resolveWorktreeBaseDir, } from "../../packages/loopover-miner/lib/worktree-allocator.js"; +import { cleanupResourceCount, resetProcessLifecycleForTesting } from "../../packages/loopover-miner/lib/process-lifecycle.js"; const roots: string[] = []; const allocators: Array<{ close(): void }> = []; @@ -98,4 +99,22 @@ describe("loopover-miner worktree allocator scaffolding (#4298)", () => { const second = allocator.acquire("attempt-a", "acme/widgets"); expect(second.worktreePath).toBe(first.worktreePath); }); + + it("registers the opened store for crash-safe cleanup and unregisters it on close (#6600)", () => { + // Opening through local-store.js's openLocalStoreDb registers the handle so a SIGINT/SIGTERM/crash + // mid-write is flushed by installCliSignalHandlers — exactly as the three sibling stores already are. + resetProcessLifecycleForTesting(); + const root = mkdtempSync(join(tmpdir(), "loopover-miner-worktree-cleanup-")); + roots.push(root); + + expect(cleanupResourceCount()).toBe(0); + const allocator = openWorktreeAllocator({ + dbPath: join(root, "worktree-allocator.sqlite3"), + worktreeBaseDir: join(root, "worktrees"), + maxConcurrency: 1, + }); + expect(cleanupResourceCount()).toBe(1); + allocator.close(); + expect(cleanupResourceCount()).toBe(0); + }); });