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
39 changes: 39 additions & 0 deletions packages/gittensory-miner/lib/worktree-allocator.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type WorktreeAllocation = {
slotIndex: number;
worktreePath: string;
attemptId: string | null;
repoFullName: string | null;
status: "free" | "active";
ownerPid: number | null;
allocatedAt: string | null;
};

export type WorktreeAllocator = {
dbPath: string;
worktreeBaseDir: string;
maxConcurrency: number;
processPid: number;
acquire(attemptId: string, repoFullName: string): WorktreeAllocation;
release(attemptId: string): WorktreeAllocation | null;
listSlots(): WorktreeAllocation[];
close(): void;
};

export function resolveWorktreeAllocatorDbPath(env?: Record<string, string | undefined>): string;

export function resolveWorktreeBaseDir(env?: Record<string, string | undefined>): string;

export function isProcessAlive(pid: number): boolean;

export function openWorktreeAllocator(options?: {
dbPath?: string;
worktreeBaseDir?: string;
maxConcurrency?: number;
processPid?: number;
}): WorktreeAllocator;

export function acquireWorktree(attemptId: string, repoFullName: string): WorktreeAllocation;

export function releaseWorktree(attemptId: string): WorktreeAllocation | null;

export function closeDefaultWorktreeAllocator(): void;
262 changes: 262 additions & 0 deletions packages/gittensory-miner/lib/worktree-allocator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { chmodSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";

// 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.

const defaultDbFileName = "worktree-allocator.sqlite3";
const defaultWorktreeDirName = "worktrees";
const defaultMaxConcurrency = 2;
let defaultWorktreeAllocator = null;

export function resolveWorktreeAllocatorDbPath(env = process.env) {
const explicitPath = typeof env.GITTENSORY_MINER_WORKTREE_ALLOCATOR_DB === "string"
? env.GITTENSORY_MINER_WORKTREE_ALLOCATOR_DB.trim()
: "";
if (explicitPath) return explicitPath;

const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
? env.GITTENSORY_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, "gittensory-miner", defaultDbFileName);
}

export function resolveWorktreeBaseDir(env = process.env) {
const explicitPath = typeof env.GITTENSORY_MINER_WORKTREE_DIR === "string"
? env.GITTENSORY_MINER_WORKTREE_DIR.trim()
: "";
if (explicitPath) return explicitPath;

const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
? env.GITTENSORY_MINER_CONFIG_DIR.trim()
: "";
if (explicitConfigDir) return join(explicitConfigDir, defaultWorktreeDirName);

const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
? env.XDG_CONFIG_HOME.trim()
: join(homedir(), ".config");
return join(configHome, "gittensory-miner", defaultWorktreeDirName);
}

function normalizeDbPath(dbPath) {
const path = (dbPath ?? resolveWorktreeAllocatorDbPath()).trim();
if (!path) throw new Error("invalid_worktree_allocator_db_path");
return path;
}

function normalizeWorktreeBaseDir(worktreeBaseDir) {
const path = (worktreeBaseDir ?? resolveWorktreeBaseDir()).trim();
if (!path) throw new Error("invalid_worktree_base_dir");
return path;
}

function normalizeMaxConcurrency(value) {
if (value === undefined || value === null) return defaultMaxConcurrency;
if (!Number.isInteger(value) || value < 1) throw new Error("invalid_max_concurrency");
return value;
}

function normalizeRepoFullName(repoFullName) {
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
const [owner, repo, extra] = repoFullName.trim().split("/");
if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
return `${owner}/${repo}`;
}

function normalizeAttemptId(attemptId) {
if (typeof attemptId !== "string") throw new Error("invalid_attempt_id");
const trimmed = attemptId.trim();
if (!trimmed) throw new Error("invalid_attempt_id");
return trimmed;
}

export function isProcessAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
// ESRCH = no such process; EPERM (or similar) means the process exists but we lack signal rights.
return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH"
? false
: true;
}
}

function rowToAllocation(row) {
return {
slotIndex: row.slot_index,
worktreePath: row.worktree_path,
attemptId: row.attempt_id,
repoFullName: row.repo_full_name,
status: row.status,
ownerPid: row.owner_pid,
allocatedAt: row.allocated_at,
};
}

function ensureSlotTable(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS worktree_slots (
slot_index INTEGER PRIMARY KEY,
worktree_path TEXT NOT NULL UNIQUE,
attempt_id TEXT UNIQUE,
repo_full_name TEXT,
status TEXT NOT NULL CHECK (status IN ('free', 'active')),
owner_pid INTEGER,
allocated_at TEXT
)
`);
}

function ensureSlots(db, worktreeBaseDir, maxConcurrency) {
mkdirSync(worktreeBaseDir, { recursive: true, mode: 0o700 });
const insert = db.prepare(`
INSERT OR IGNORE INTO worktree_slots (slot_index, worktree_path, status)
VALUES (?, ?, 'free')
`);
for (let slotIndex = 0; slotIndex < maxConcurrency; slotIndex += 1) {
const worktreePath = join(worktreeBaseDir, `slot-${slotIndex}`);
insert.run(slotIndex, worktreePath);
mkdirSync(worktreePath, { recursive: true, mode: 0o700 });
}
}

function reclaimOrphanedAllocations(db) {
const orphans = db
.prepare("SELECT slot_index, owner_pid FROM worktree_slots WHERE status = 'active'")
.all();
const reclaim = db.prepare(`
UPDATE worktree_slots
SET status = 'free', attempt_id = NULL, repo_full_name = NULL, owner_pid = NULL, allocated_at = NULL
WHERE slot_index = ?
`);
for (const row of orphans) {
if (row.owner_pid !== null && isProcessAlive(row.owner_pid)) continue;
reclaim.run(row.slot_index);
}
}

/**
* Opens the local worktree allocator store. Reclaims orphaned active slots from dead owner processes on startup.
*/
export function openWorktreeAllocator(options = {}) {
const resolvedPath = normalizeDbPath(options.dbPath);
const worktreeBaseDir = normalizeWorktreeBaseDir(options.worktreeBaseDir);
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");
ensureSlotTable(db);
ensureSlots(db, worktreeBaseDir, maxConcurrency);
reclaimOrphanedAllocations(db);

const getByAttempt = db.prepare(
"SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, allocated_at FROM worktree_slots WHERE attempt_id = ?",
);
const countActive = db.prepare("SELECT COUNT(*) AS count FROM worktree_slots WHERE status = 'active'");
const selectFreeSlot = db.prepare(`
SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, allocated_at
FROM worktree_slots
WHERE status = 'free'
ORDER BY slot_index
LIMIT 1
`);
const markActive = db.prepare(`
UPDATE worktree_slots
SET status = 'active', attempt_id = ?, repo_full_name = ?, owner_pid = ?, allocated_at = ?
WHERE slot_index = ?
`);
const releaseByAttempt = db.prepare(`
UPDATE worktree_slots
SET status = 'free', attempt_id = NULL, repo_full_name = NULL, owner_pid = NULL, allocated_at = NULL
WHERE attempt_id = ? AND status = 'active'
RETURNING slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, allocated_at
`);
const listSlots = db.prepare(
"SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, allocated_at FROM worktree_slots ORDER BY slot_index",
);

const allocator = {
dbPath: resolvedPath,
worktreeBaseDir,
maxConcurrency,
processPid,
acquire(attemptId, repoFullName) {
const normalizedAttempt = normalizeAttemptId(attemptId);
const normalizedRepo = normalizeRepoFullName(repoFullName);
const existing = getByAttempt.get(normalizedAttempt);
if (existing?.status === "active") return rowToAllocation(existing);

db.exec("BEGIN IMMEDIATE");
try {
const raced = getByAttempt.get(normalizedAttempt);
if (raced?.status === "active") {
db.exec("COMMIT");
return rowToAllocation(raced);
}
const activeCount = countActive.get().count;
if (activeCount >= maxConcurrency) throw new Error("worktree_capacity_exceeded");
const slot = selectFreeSlot.get();
if (!slot) throw new Error("worktree_capacity_exceeded");
const allocatedAt = new Date().toISOString();
markActive.run(normalizedAttempt, normalizedRepo, processPid, allocatedAt, slot.slot_index);
db.exec("COMMIT");
return rowToAllocation({
...slot,
attempt_id: normalizedAttempt,
repo_full_name: normalizedRepo,
status: "active",
owner_pid: processPid,
allocated_at: allocatedAt,
});
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
},
release(attemptId) {
const normalizedAttempt = normalizeAttemptId(attemptId);
const row = releaseByAttempt.get(normalizedAttempt);
return row ? rowToAllocation(row) : null;
},
listSlots() {
return listSlots.all().map(rowToAllocation);
},
close() {
db.close();
},
};

return allocator;
}

function getDefaultWorktreeAllocator() {
defaultWorktreeAllocator ??= openWorktreeAllocator();
return defaultWorktreeAllocator;
}

export function acquireWorktree(attemptId, repoFullName) {
return getDefaultWorktreeAllocator().acquire(attemptId, repoFullName);
}

export function releaseWorktree(attemptId) {
return getDefaultWorktreeAllocator().release(attemptId);
}

export function closeDefaultWorktreeAllocator() {
if (!defaultWorktreeAllocator) return;
defaultWorktreeAllocator.close();
defaultWorktreeAllocator = null;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"expected-engine.version"
],
"scripts": {
"build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js"
"build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js"
},
"dependencies": {
"@jsonbored/gittensory-engine": ">=0.1.0 <1.0.0"
Expand Down
39 changes: 39 additions & 0 deletions test/fixtures/miner-worktree-allocator/acquire-child.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
// Cross-process helper for worktree-allocator collision tests (#4298).
// Opens the shared store, waits for a stdin "go" signal, then calls acquire() so
// multiple Node processes contend on BEGIN IMMEDIATE against the same dbPath.
import { openWorktreeAllocator } from "../../../packages/gittensory-miner/lib/worktree-allocator.js";

const [dbPath, worktreeBaseDir, maxConcurrencyStr, attemptId, repoFullName] = process.argv.slice(2);
if (!dbPath || !worktreeBaseDir || !maxConcurrencyStr || !attemptId || !repoFullName) {
process.stderr.write("usage: acquire-child.mjs <dbPath> <worktreeBaseDir> <maxConcurrency> <attemptId> <repoFullName>\n");
process.exit(2);
}

const allocator = openWorktreeAllocator({
dbPath,
worktreeBaseDir,
maxConcurrency: Number(maxConcurrencyStr),
});

let started = false;

function runAcquire() {
if (started) return;
started = true;
try {
const allocation = allocator.acquire(attemptId, repoFullName);
process.stdout.write(`${JSON.stringify({ ok: true, allocation })}\n`);
process.exit(0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stdout.write(`${JSON.stringify({ ok: false, message })}\n`);
process.exit(1);
} finally {
allocator.close();
}
}

process.stdin.setEncoding("utf8");
process.stdin.on("data", () => runAcquire());
process.stdout.write("READY\n");
Loading