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
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export {
type WorktreePlan,
type WorktreeRemoveResult,
} from "./miner/worktree-allocator.js";
export * from "./miner/worktree-pool.js";
export {
invokeCodingAgentDriver,
type AttemptLogSink,
Expand Down
100 changes: 100 additions & 0 deletions packages/gittensory-engine/src/miner/worktree-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Git-worktree POOL allocator (#4297): the pure, in-memory scheduling logic for a POOL of per-attempt git
// worktrees across concurrent fleet attempts — acquire/release under a concurrency cap, plus orphan
// reclamation. Complementary to the isolation PRIMITIVE (worktree-allocator.ts, #4269), which plans/creates/
// tears down ONE worktree; this manages the SET of them so two concurrent attempts never collide and a crash
// can't leak worktree slots forever.
//
// Per #4297, the pure allocation logic lives here in gittensory-engine so it is unit-testable WITHOUT touching
// a real filesystem or database. The thin bookkeeping wrapper that PERSISTS this state (local SQLite, the same
// way claim-ledger.js / run-state.js do) is a separate miner-package layer — it holds a WorktreePoolState,
// calls these pure transitions, and writes the result back. No IO here: every function takes state in and
// returns new state out.

import { planWorktree, type WorktreePlan } from "./worktree-allocator.js";

/** One live allocation: which attempt holds which planned worktree. */
export type WorktreeAllocation = {
attemptId: string;
repoPath: string;
plan: WorktreePlan;
};

/** The pool's whole allocation state — a serializable snapshot the persistence wrapper stores. */
export type WorktreePoolState = {
allocations: readonly WorktreeAllocation[];
};

export type WorktreePoolConfig = {
/** Maximum concurrent worktrees. A non-positive cap allocates nothing. */
maxConcurrency: number;
};

/** The empty pool — nothing allocated. */
export const EMPTY_WORKTREE_POOL: WorktreePoolState = { allocations: [] };

export type AcquireWorktreeResult =
| { ok: true; state: WorktreePoolState; allocation: WorktreeAllocation }
| { ok: false; reason: "already_allocated" | "at_capacity"; state: WorktreePoolState };

/** True when `attemptId` currently holds an allocation. Pure. */
export function isWorktreeAllocated(state: WorktreePoolState, attemptId: string): boolean {
return state.allocations.some((allocation) => allocation.attemptId === attemptId);
}

/** Slots still available before the concurrency cap (never negative). Pure. */
export function availableWorktreeSlots(state: WorktreePoolState, config: WorktreePoolConfig): number {
return Math.max(0, config.maxConcurrency - state.allocations.length);
}

/**
* Acquire a worktree slot for an attempt. The slot's path/branch is derived deterministically from the
* attempt id via {@link planWorktree}. Fails WITHOUT mutating when the attempt already holds a slot
* (`already_allocated`, idempotency guard) or the pool is at its concurrency cap (`at_capacity`). Pure —
* returns a new state on success.
*/
export function acquireWorktree(
state: WorktreePoolState,
config: WorktreePoolConfig,
input: { attemptId: string; repoPath: string },
): AcquireWorktreeResult {
if (isWorktreeAllocated(state, input.attemptId)) {
return { ok: false, reason: "already_allocated", state };
}
if (state.allocations.length >= config.maxConcurrency) {
return { ok: false, reason: "at_capacity", state };
}
const allocation: WorktreeAllocation = {
attemptId: input.attemptId,
repoPath: input.repoPath,
plan: planWorktree({ repoPath: input.repoPath, attemptId: input.attemptId }),
};
return { ok: true, state: { allocations: [...state.allocations, allocation] }, allocation };
}

/**
* Release an attempt's slot, freeing it for reuse. Pure and idempotent — releasing an attempt that holds no
* slot returns an equivalent state. The caller tears down the actual worktree (via the primitive) separately.
*/
export function releaseWorktree(state: WorktreePoolState, attemptId: string): WorktreePoolState {
const allocations = state.allocations.filter((allocation) => allocation.attemptId !== attemptId);
return allocations.length === state.allocations.length ? state : { allocations };
}

/**
* Reclaim orphaned slots: free every allocation whose attempt is no longer in the live set (e.g. after a crash
* left the bookkeeping ahead of reality). Returns the surviving state plus the reclaimed allocations so the
* caller can tear down their leaked worktrees (or flag them for manual cleanup) rather than leaking forever.
* Pure.
*/
export function reclaimOrphanedWorktrees(
state: WorktreePoolState,
liveAttemptIds: Iterable<string>,
): { state: WorktreePoolState; reclaimed: WorktreeAllocation[] } {
const live = new Set(liveAttemptIds);
const reclaimed: WorktreeAllocation[] = [];
const remaining: WorktreeAllocation[] = [];
for (const allocation of state.allocations) {
(live.has(allocation.attemptId) ? remaining : reclaimed).push(allocation);
}
return { state: reclaimed.length === 0 ? state : { allocations: remaining }, reclaimed };
}
81 changes: 81 additions & 0 deletions test/unit/miner-worktree-pool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_WORKTREE_POOL,
acquireWorktree,
releaseWorktree,
reclaimOrphanedWorktrees,
isWorktreeAllocated,
availableWorktreeSlots,
type WorktreePoolState,
} from "../../packages/gittensory-engine/src/index";

const config = { maxConcurrency: 2 };
const REPO = "/home/node/repos/acme";

function acquired(state: WorktreePoolState, attemptId: string): WorktreePoolState {
const r = acquireWorktree(state, config, { attemptId, repoPath: REPO });
if (!r.ok) throw new Error(`unexpected acquire failure: ${r.reason}`);
return r.state;
}

describe("worktree pool allocator (#4297)", () => {
it("acquires a slot with a deterministic plan derived from the attempt id", () => {
const r = acquireWorktree(EMPTY_WORKTREE_POOL, config, { attemptId: "attempt-1", repoPath: REPO });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.allocation.attemptId).toBe("attempt-1");
expect(r.allocation.plan.worktreePath).toContain(".gittensory-worktrees");
expect(r.allocation.plan.branchName).toContain("gittensory/attempt/");
expect(r.state.allocations).toHaveLength(1);
expect(isWorktreeAllocated(r.state, "attempt-1")).toBe(true);
expect(availableWorktreeSlots(r.state, config)).toBe(1);
});

it("refuses a second slot for the same attempt (already_allocated, no mutation)", () => {
const first = acquired(EMPTY_WORKTREE_POOL, "attempt-1");
const again = acquireWorktree(first, config, { attemptId: "attempt-1", repoPath: REPO });
expect(again.ok).toBe(false);
if (again.ok) return;
expect(again.reason).toBe("already_allocated");
expect(again.state).toBe(first); // unchanged reference
});

it("enforces the concurrency cap (at_capacity)", () => {
const full = acquired(acquired(EMPTY_WORKTREE_POOL, "a"), "b");
expect(availableWorktreeSlots(full, config)).toBe(0);
const third = acquireWorktree(full, config, { attemptId: "c", repoPath: REPO });
expect(third.ok).toBe(false);
if (third.ok) return;
expect(third.reason).toBe("at_capacity");
expect(third.state).toBe(full);
});

it("releases a slot, freeing capacity, and is a no-op for an unknown attempt", () => {
const s = acquired(EMPTY_WORKTREE_POOL, "a");
const released = releaseWorktree(s, "a");
expect(isWorktreeAllocated(released, "a")).toBe(false);
expect(availableWorktreeSlots(released, config)).toBe(2);
const noop = releaseWorktree(released, "ghost");
expect(noop).toBe(released); // no matching allocation ⇒ same state reference
});

it("reclaims orphaned slots whose attempt is no longer live", () => {
const s = acquired(acquired(EMPTY_WORKTREE_POOL, "live"), "dead");
const { state, reclaimed } = reclaimOrphanedWorktrees(s, ["live"]);
expect(reclaimed.map((a) => a.attemptId)).toEqual(["dead"]);
expect(isWorktreeAllocated(state, "dead")).toBe(false);
expect(isWorktreeAllocated(state, "live")).toBe(true);
});

it("reclaim is a no-op (same state) when every allocation is still live", () => {
const s = acquired(EMPTY_WORKTREE_POOL, "live");
const { state, reclaimed } = reclaimOrphanedWorktrees(s, ["live", "other"]);
expect(reclaimed).toEqual([]);
expect(state).toBe(s); // unchanged reference
});

it("availableWorktreeSlots clamps at 0 when allocations exceed a shrunk cap", () => {
const s = acquired(acquired(EMPTY_WORKTREE_POOL, "a"), "b");
expect(availableWorktreeSlots(s, { maxConcurrency: 1 })).toBe(0);
});
});