From fc177e0c05e46195c9873740bd32f5a2f5bdf52b Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:04:53 +0900 Subject: [PATCH] feat(miner-concurrency): add git-worktree-per-attempt pool allocator (#4297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New packages/gittensory-engine/src/miner/worktree-pool.ts: the pure, in-memory scheduling logic for a POOL of per-attempt git worktrees across concurrent fleet attempts, complementary to the isolation primitive (worktree-allocator.ts, #4269). acquire/release under a configurable concurrency cap, plus orphan reclamation (free slots whose attempt is no longer live). Each slot's path/branch is derived via the primitive's planWorktree. Per #4297, the pure allocation logic lives in gittensory-engine so it is unit-testable without a real filesystem or DB; the SQLite bookkeeping wrapper that persists WorktreePoolState is a thin miner-package layer. No IO here — every function takes state in, returns new state out. Closes #4297 --- packages/gittensory-engine/src/index.ts | 1 + .../src/miner/worktree-pool.ts | 100 ++++++++++++++++++ test/unit/miner-worktree-pool.test.ts | 81 ++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 packages/gittensory-engine/src/miner/worktree-pool.ts create mode 100644 test/unit/miner-worktree-pool.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 7b525c74c8..0db9b389fe 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -210,6 +210,7 @@ export { type WorktreePlan, type WorktreeRemoveResult, } from "./miner/worktree-allocator.js"; +export * from "./miner/worktree-pool.js"; export { invokeCodingAgentDriver, type AttemptLogSink, diff --git a/packages/gittensory-engine/src/miner/worktree-pool.ts b/packages/gittensory-engine/src/miner/worktree-pool.ts new file mode 100644 index 0000000000..fd7ee18baf --- /dev/null +++ b/packages/gittensory-engine/src/miner/worktree-pool.ts @@ -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, +): { 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 }; +} diff --git a/test/unit/miner-worktree-pool.test.ts b/test/unit/miner-worktree-pool.test.ts new file mode 100644 index 0000000000..edba552546 --- /dev/null +++ b/test/unit/miner-worktree-pool.test.ts @@ -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); + }); +});