Archive task worktrees through a store-scoped, race-safe disposal lifecycle. - Reserve pinned worktree paths during archive cleanup and successor creation. - Reconcile quarantined removals before reusing a pinned path. - Gate PostgreSQL archival before destructive worktree disposal and wire CLI cleanup. Files changed: .changeset/fn-8105-archive-removes-worktree.md | 7 + docs/task-management.md | 4 + .../extension-experiment-finalize.test.ts | 1 + .../src/__tests__/extension-fn-secret-get.test.ts | 1 + .../extension-gitlab-tracking.test.ts | 1 + .../cli/src/__tests__/extension-web-fetch.test.ts | 1 + .../task-command-github-import-tracking.test.ts | 1 + packages/cli/src/commands/__tests__/task.test.ts | 1 + packages/cli/src/commands/task.ts | 8 +- packages/cli/src/extension.ts | 4 + .../__tests__/worktree-path-reservation.test.ts | 58 ++++++++ packages/core/src/archive-worktree-disposer.ts | 21 +++ packages/core/src/index.gate.ts | 13 ++ packages/core/src/index.ts | 13 ++ .../core/src/task-store/archive-lifecycle-2.ts | 8 ++ packages/core/src/task-store/archive-lifecycle.ts | 37 +++++ packages/core/src/worktree-path-reservation.ts | 149 +++++++++++++++++++++ .../src/archive-worktree-disposer-install.ts | 18 +++ packages/engine/src/executor.ts | 16 +++ packages/engine/src/index.ts | 2 + packages/engine/src/runtimes/in-process-runtime.ts | 1 + packages/engine/src/worktree-acquisition.ts | 27 +++- 22 files changed, 388 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8105 Fusion-Task-Lineage: cabb8f52-093f-4986-bfda-2c7601a72579 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
150 lines
7.1 KiB
TypeScript
150 lines
7.1 KiB
TypeScript
import {createHash, randomUUID} from "node:crypto";
|
|
import {hostname} from "node:os";
|
|
import {dirname, join, resolve} from "node:path";
|
|
import {mkdir, readFile, rename, rm, rmdir, unlink, writeFile} from "node:fs/promises";
|
|
|
|
/**
|
|
* FNXC:WorkflowLifecycle 2026-07-16-10:00:
|
|
* Pinned worktrees are reused by independent engine processes, so archive and
|
|
* acquisition share a host-scoped filesystem reservation. `claim/` is the
|
|
* exclusive mkdir create-or-fail primitive; the durable state record is never
|
|
* used as a lock because rename-over-destination does not exclude contenders.
|
|
*/
|
|
export type WorktreeReservationState = "held" | "released" | "quarantined";
|
|
export interface WorktreePathReservation {
|
|
canonicalPath: string;
|
|
token: string;
|
|
previousState: "free" | "quarantined";
|
|
state: WorktreeReservationState;
|
|
release(): Promise<void>;
|
|
quarantine(reason: string): Promise<void>;
|
|
}
|
|
interface ReservationRecord {
|
|
pid: number;
|
|
hostname: string;
|
|
startedAt: string;
|
|
canonicalPath: string;
|
|
state: "held" | "quarantined";
|
|
token: string;
|
|
reason?: string;
|
|
}
|
|
export interface WorktreePathReservationOptions {
|
|
canonicalPath: string;
|
|
worktreesDir: string;
|
|
rootDir: string;
|
|
isLiveWorktree?: (canonicalPath: string) => Promise<boolean>;
|
|
reconcileQuarantined?: (canonicalPath: string) => Promise<void>;
|
|
ttlMs?: number;
|
|
pollMs?: number;
|
|
acquireTimeoutMs?: number;
|
|
}
|
|
|
|
export async function canonicalizeWorktreePath(path: string): Promise<string> {
|
|
// resolve is intentional: absent orphan paths still need one stable lock key.
|
|
return resolve(path);
|
|
}
|
|
|
|
function paths(worktreesDir: string, canonicalPath: string) {
|
|
const key = createHash("sha256").update(canonicalPath).digest("hex");
|
|
const container = join(resolve(worktreesDir), ".fusion-worktree-locks", key);
|
|
return {container, claim: join(container, "claim"), state: join(container, "state.json")};
|
|
}
|
|
|
|
async function readRecord(statePath: string): Promise<ReservationRecord | null> {
|
|
try {
|
|
const value = JSON.parse(await readFile(statePath, "utf8")) as ReservationRecord;
|
|
return value?.token && value?.canonicalPath ? value : null;
|
|
} catch { return null; }
|
|
}
|
|
async function writeRecord(statePath: string, record: ReservationRecord): Promise<void> {
|
|
const temp = join(dirname(statePath), `.state-${process.pid}-${randomUUID()}.tmp`);
|
|
await writeFile(temp, JSON.stringify(record), "utf8");
|
|
await rename(temp, statePath);
|
|
}
|
|
function pidAlive(pid: number): boolean {
|
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
try { process.kill(pid, 0); return true; } catch (error: unknown) {
|
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
}
|
|
}
|
|
const sleep = (ms: number) => new Promise<void>((done) => setTimeout(done, ms));
|
|
|
|
/** Read the durable record; it is diagnostic only and does not imply ownership. */
|
|
export async function readWorktreePathReservation(options: Pick<WorktreePathReservationOptions, "canonicalPath" | "worktreesDir">): Promise<ReservationRecord | null> {
|
|
const canonicalPath = await canonicalizeWorktreePath(options.canonicalPath);
|
|
return readRecord(paths(options.worktreesDir, canonicalPath).state);
|
|
}
|
|
|
|
export async function acquireWorktreePathReservation(options: WorktreePathReservationOptions): Promise<WorktreePathReservation> {
|
|
const canonicalPath = await canonicalizeWorktreePath(options.canonicalPath);
|
|
const layout = paths(options.worktreesDir, canonicalPath);
|
|
const pollMs = options.pollMs ?? 25;
|
|
const timeoutMs = options.acquireTimeoutMs ?? 30_000;
|
|
const ttlMs = options.ttlMs ?? 10 * 60_000;
|
|
const started = Date.now();
|
|
await mkdir(layout.container, {recursive: true});
|
|
|
|
for (;;) {
|
|
try {
|
|
await mkdir(layout.claim);
|
|
const prior = await readRecord(layout.state);
|
|
const token = randomUUID();
|
|
const record: ReservationRecord = {pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString(), canonicalPath, state: "held", token};
|
|
await writeRecord(layout.state, record);
|
|
let state: WorktreeReservationState = "held";
|
|
const settle = async (quarantineReason?: string): Promise<void> => {
|
|
if (state !== "held") return;
|
|
const current = await readRecord(layout.state);
|
|
if (!current || current.token !== token) { state = "released"; return; }
|
|
if (quarantineReason !== undefined) {
|
|
await writeRecord(layout.state, {...current, state: "quarantined", reason: quarantineReason});
|
|
await rmdir(layout.claim).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; });
|
|
state = "quarantined";
|
|
} else {
|
|
await unlink(layout.state).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; });
|
|
await rmdir(layout.claim).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; });
|
|
state = "released";
|
|
}
|
|
};
|
|
const previousState = prior?.state === "quarantined" || prior?.state === "held" ? "quarantined" : "free";
|
|
if (previousState === "quarantined" && options.reconcileQuarantined) {
|
|
/*
|
|
FNXC:WorkflowLifecycle 2026-07-16-10:00:
|
|
A failed archive removal is retried by the successor while its exclusive
|
|
claim is held. Re-quarantine on failure so it cannot attempt creation at
|
|
the still-occupied pinned path.
|
|
*/
|
|
try {
|
|
await options.reconcileQuarantined(canonicalPath);
|
|
} catch (error) {
|
|
await settle(error instanceof Error ? error.message : String(error));
|
|
throw error;
|
|
}
|
|
}
|
|
return {canonicalPath, token, previousState, get state() { return state; }, release: () => settle(), quarantine: (reason) => settle(reason)};
|
|
} catch (error: unknown) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
const record = await readRecord(layout.state);
|
|
const age = record ? Date.now() - Date.parse(record.startedAt) : 0;
|
|
const deadLocalOwner = !!record && record.hostname === hostname() && !pidAlive(record.pid);
|
|
let stale = deadLocalOwner;
|
|
if (!stale && record && age > ttlMs) {
|
|
// Fail closed: inability to prove non-liveness never steals a claim.
|
|
try { stale = !(await (options.isLiveWorktree?.(canonicalPath) ?? Promise.resolve(true))); } catch { stale = false; }
|
|
}
|
|
if (stale) {
|
|
const reclaimed = join(layout.container, `.reclaim-${randomUUID()}`);
|
|
try { await rename(layout.claim, reclaimed); await rm(reclaimed, {recursive: true, force: true}); continue; }
|
|
catch (reclaimError: unknown) { if ((reclaimError as NodeJS.ErrnoException).code !== "ENOENT") throw reclaimError; continue; }
|
|
}
|
|
if (Date.now() - started >= timeoutMs) throw new Error(`Timed out acquiring worktree reservation for ${canonicalPath} after ${timeoutMs}ms`);
|
|
await sleep(pollMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function withWorktreePathReservation<T>(options: WorktreePathReservationOptions, fn: (reservation: WorktreePathReservation) => Promise<T>): Promise<T> {
|
|
const reservation = await acquireWorktreePathReservation(options);
|
|
try { return await fn(reservation); } finally { if (reservation.state === "held") await reservation.release(); }
|
|
}
|