fix: refresh dependent worktrees after local merges (#3381)
## Summary - apply Fusion's existing stale-base reconciliation to freshly reacquired and pooled execution worktrees - advance retained task branches to the current local integration commit after dependencies land - keep planning and Worktrunk behavior unchanged while preserving dirty/conflict fail-closed handling ## Problem A dependent task can be planned before its dependency lands. If the dependency merges and its branch is deleted, a later execution retry may recreate the dependent worktree from its already-existing task branch. That branch can still point at the pre-dependency commit. Fusion already refreshes reused execution worktrees, but fresh acquisition returned without calling the same reconciliation primitive. The dependent task therefore executed without the landed dependency output even though Fusion marked the dependency complete. ## Fix When `refreshStaleBase` is enabled, run `refreshReusedWorktreeBase` after a native fresh or pooled worktree is acquired and before cleanup, init, or session execution. Track the actual backend used by injected and fallback creators so a native fallback still refreshes while Worktrunk-managed paths remain excluded. The existing primitive: - resolves the current local integration branch without requiring a remote - resets branches with no task-owned commits - rebases branches with task-owned commits - blocks dirty or conflicting worktrees - persists the integration commit as `baseCommitSha` If refresh blocks a pooled checkout, clear the task's durable binding before releasing the checkout for reuse. Planning callers do not enable `refreshStaleBase`, so planning worktrees remain unchanged. ## Verification - `pnpm --filter @fusion/engine exec vitest run src/__tests__/worktree-base-refresh.test.ts src/__tests__/worktree-acquisition.test.ts --silent=passed-only --reporter=dot` — 34 passed - `pnpm --filter @fusion/engine typecheck` - `pnpm --filter @fusion/engine build` - `pnpm test:gate:static` - `pnpm check:changesets` - `git diff --check` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved worktree acquisition by refreshing stale branches against the current integration branch. * Added refresh support for recreated, pooled, and native fallback worktrees. * Prevented task execution when refresh fails and safely released affected pooled worktrees. * Avoided unnecessary refreshes for newly created Worktrunk worktrees. * **Tests** * Added coverage for stale-base refresh behavior across supported acquisition scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/dependent-worktree-base-refresh.md
Normal file
7
.changeset/dependent-worktree-base-refresh.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Refresh reacquired execution worktrees against the current local integration branch.
|
||||
category: fix
|
||||
dev: Apply the existing stale-base reconciliation to native fresh and pooled acquisitions so a retained task branch cannot omit a dependency that landed while its original base branch disappeared.
|
||||
@@ -141,11 +141,12 @@ describe("worktree-acquisition secrets env hook", () => {
|
||||
expect(refreshReusedWorktreeBase).not.toHaveBeenCalled();
|
||||
expect(git).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "worktree:base-refresh-blocked",
|
||||
target: "FN-1",
|
||||
target: existingWorktree,
|
||||
metadata: { taskId: "FN-1", outcome: "base-reconciliation-required", reconciliationOutcome: "git-dir-unavailable" },
|
||||
}));
|
||||
// FNXC:SecretsEnvMaterialization 2026-08-09-03:50: Audit targets identify the affected checkout; only
|
||||
// resolver diagnostics and secret-derived values are redacted from the fixed failure outcome.
|
||||
expect(JSON.stringify(git.mock.calls)).not.toContain("secret-derived resolver detail");
|
||||
expect(JSON.stringify(git.mock.calls)).not.toContain(existingWorktree);
|
||||
});
|
||||
|
||||
it("isolates writer failures", async () => {
|
||||
|
||||
@@ -4,10 +4,11 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { acquireTaskWorktree, RepoRootWorktreeError } from "../worktree/worktree-acquisition.js";
|
||||
import { acquireTaskWorktree, RepoRootWorktreeError, WorktreeBaseRefreshError } from "../worktree/worktree-acquisition.js";
|
||||
import { classifyTaskWorktree, PoolDoubleLeaseError } from "../worktree/worktree-pool.js";
|
||||
import * as desktopArtifacts from "../worktree/worktree-desktop-artifacts.js";
|
||||
import * as branchConflicts from "../execution/branch-conflicts.js";
|
||||
import { NativeWorktreeBackend } from "../worktree/worktree-backend.js";
|
||||
|
||||
vi.mock("../worktree/worktree-pool.js", async () => {
|
||||
const actual = await vi.importActual<any>("../worktree/worktree-pool.js");
|
||||
@@ -344,6 +345,222 @@ describe("acquireTaskWorktree", () => {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: null, branch: null, sessionFile: null });
|
||||
});
|
||||
|
||||
it("refreshes a recreated existing task branch after its dependency branch is deleted", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const staleBase = git(rootDir, "git rev-parse HEAD");
|
||||
git(rootDir, `git branch fusion/fn-4 ${staleBase}`);
|
||||
git(rootDir, `git checkout -b fusion/deleted-dependency ${staleBase}`);
|
||||
writeFileSync(join(rootDir, "dependency-output.ts"), "export const dependencyOutput = true;\n", "utf-8");
|
||||
git(rootDir, "git add dependency-output.ts");
|
||||
git(rootDir, 'git commit -m "land dependency"');
|
||||
git(rootDir, "git checkout main");
|
||||
git(rootDir, "git merge --ff-only fusion/deleted-dependency");
|
||||
git(rootDir, "git branch -d fusion/deleted-dependency");
|
||||
const landedBase = git(rootDir, "git rev-parse HEAD");
|
||||
|
||||
const result = await acquireTaskWorktree({
|
||||
task: {
|
||||
...task,
|
||||
id: "FN-4",
|
||||
branch: "fusion/fn-4",
|
||||
baseCommitSha: staleBase,
|
||||
executionStartBranch: "fusion/deleted-dependency",
|
||||
},
|
||||
rootDir,
|
||||
store,
|
||||
settings: { worktreeNaming: "task-id", recycleWorktrees: false },
|
||||
refreshStaleBase: true,
|
||||
createWorktree: async (branch, path) => {
|
||||
git(rootDir, `git worktree add ${JSON.stringify(path)} ${JSON.stringify(branch)}`);
|
||||
return { path, branch };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
source: "fresh",
|
||||
baseRefresh: { kind: "reset-to-base", executionSafe: true, baseSha: landedBase },
|
||||
});
|
||||
expect(git(result.worktreePath, "git rev-parse HEAD")).toBe(landedBase);
|
||||
expect(existsSync(join(result.worktreePath, "dependency-output.ts"))).toBe(true);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-4", { baseCommitSha: landedBase });
|
||||
});
|
||||
|
||||
it("refreshes a retained task branch acquired from the worktree pool", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const staleBase = git(rootDir, "git rev-parse HEAD");
|
||||
const pooledPath = join(rootDir, ".worktrees", "pooled-fn-4");
|
||||
git(rootDir, `git worktree add -b fusion/fn-4 ${JSON.stringify(pooledPath)} ${staleBase}`);
|
||||
writeFileSync(join(rootDir, "dependency-output.ts"), "export const dependencyOutput = true;\n", "utf-8");
|
||||
git(rootDir, "git add dependency-output.ts");
|
||||
git(rootDir, 'git commit -m "land dependency"');
|
||||
const landedBase = git(rootDir, "git rev-parse HEAD");
|
||||
const pool = {
|
||||
acquire: vi.fn().mockReturnValue(pooledPath),
|
||||
prepareForTask: vi.fn().mockResolvedValue({
|
||||
branch: "fusion/fn-4",
|
||||
worktreePath: pooledPath,
|
||||
reclaimed: false,
|
||||
}),
|
||||
release: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const result = await acquireTaskWorktree({
|
||||
task: { ...task, id: "FN-4", branch: "fusion/fn-4", baseCommitSha: staleBase },
|
||||
rootDir,
|
||||
store,
|
||||
settings: { recycleWorktrees: true },
|
||||
pool,
|
||||
refreshStaleBase: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
source: "pool",
|
||||
baseRefresh: { kind: "reset-to-base", executionSafe: true, baseSha: landedBase },
|
||||
});
|
||||
expect(git(pooledPath, "git rev-parse HEAD")).toBe(landedBase);
|
||||
expect(existsSync(join(pooledPath, "dependency-output.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not apply the native stale-base refresh to fresh Worktrunk acquisitions", async () => {
|
||||
const result = await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: process.cwd(),
|
||||
store,
|
||||
settings: { worktrunk: { enabled: true } } as any,
|
||||
backend: { kind: "worktrunk" } as any,
|
||||
createWorktreeBackendKind: "worktrunk",
|
||||
refreshStaleBase: true,
|
||||
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/worktrunk-fresh", branch: "fusion/fn-1" }),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "fresh", baseRefresh: undefined });
|
||||
});
|
||||
|
||||
it("persists the backend used by an internally created worktree", async () => {
|
||||
const rootDir = makeRepo();
|
||||
|
||||
const result = await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir,
|
||||
store,
|
||||
settings: { recycleWorktrees: false },
|
||||
backend: new NativeWorktreeBackend(),
|
||||
});
|
||||
|
||||
const markerPath = git(result.worktreePath, "git rev-parse --git-path fusion-worktree-backend-kind");
|
||||
expect(readFileSync(markerPath, "utf-8")).toBe("native\n");
|
||||
});
|
||||
|
||||
it("refreshes a native fallback acquisition even when Worktrunk is enabled", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const staleBase = git(rootDir, "git rev-parse HEAD");
|
||||
git(rootDir, `git branch fusion/fn-4 ${staleBase}`);
|
||||
writeFileSync(join(rootDir, "dependency-output.ts"), "export const dependencyOutput = true;\n", "utf-8");
|
||||
git(rootDir, "git add dependency-output.ts");
|
||||
git(rootDir, 'git commit -m "land dependency"');
|
||||
const landedBase = git(rootDir, "git rev-parse HEAD");
|
||||
|
||||
const result = await acquireTaskWorktree({
|
||||
task: { ...task, id: "FN-4", branch: "fusion/fn-4", baseCommitSha: staleBase },
|
||||
rootDir,
|
||||
store,
|
||||
settings: { worktreeNaming: "task-id", recycleWorktrees: false, worktrunk: { enabled: true } } as any,
|
||||
backend: { kind: "worktrunk" } as any,
|
||||
createWorktreeBackendKind: "native",
|
||||
refreshStaleBase: true,
|
||||
createWorktree: async (branch, path) => {
|
||||
git(rootDir, `git worktree add ${JSON.stringify(path)} ${JSON.stringify(branch)}`);
|
||||
return { path, branch };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
source: "fresh",
|
||||
baseRefresh: { kind: "reset-to-base", executionSafe: true, baseSha: landedBase },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the persisted native backend when a Worktrunk fallback is reused", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const staleBase = git(rootDir, "git rev-parse HEAD");
|
||||
const worktreePath = join(rootDir, ".worktrees", "fallback-fn-4");
|
||||
git(rootDir, `git worktree add -b fusion/fn-4 ${JSON.stringify(worktreePath)} ${staleBase}`);
|
||||
const markerPath = git(worktreePath, "git rev-parse --git-path fusion-worktree-backend-kind");
|
||||
writeFileSync(markerPath, "native\n", "utf-8");
|
||||
writeFileSync(join(rootDir, "dependency-output.ts"), "export const dependencyOutput = true;\n", "utf-8");
|
||||
git(rootDir, "git add dependency-output.ts");
|
||||
git(rootDir, 'git commit -m "land dependency"');
|
||||
const landedBase = git(rootDir, "git rev-parse HEAD");
|
||||
|
||||
const result = await acquireTaskWorktree({
|
||||
task: { ...task, id: "FN-4", worktree: worktreePath, branch: "fusion/fn-4", baseCommitSha: staleBase },
|
||||
rootDir,
|
||||
store,
|
||||
settings: { worktrunk: { enabled: true } } as any,
|
||||
backend: { kind: "worktrunk" } as any,
|
||||
refreshStaleBase: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
source: "existing",
|
||||
baseRefresh: { kind: "reset-to-base", executionSafe: true, baseSha: landedBase },
|
||||
});
|
||||
expect(git(worktreePath, "git rev-parse HEAD")).toBe(landedBase);
|
||||
});
|
||||
|
||||
it("preserves a persisted Worktrunk backend when an injected native creator is available", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const staleBase = git(rootDir, "git rev-parse HEAD");
|
||||
const worktreePath = join(rootDir, ".worktrees", "worktrunk-fn-4");
|
||||
git(rootDir, `git worktree add -b fusion/fn-4 ${JSON.stringify(worktreePath)} ${staleBase}`);
|
||||
const markerPath = git(worktreePath, "git rev-parse --git-path fusion-worktree-backend-kind");
|
||||
writeFileSync(markerPath, "worktrunk\n", "utf-8");
|
||||
writeFileSync(join(rootDir, "dependency-output.ts"), "export const dependencyOutput = true;\n", "utf-8");
|
||||
git(rootDir, "git add dependency-output.ts");
|
||||
git(rootDir, 'git commit -m "land dependency"');
|
||||
|
||||
const result = await acquireTaskWorktree({
|
||||
task: { ...task, id: "FN-4", worktree: worktreePath, branch: "fusion/fn-4", baseCommitSha: staleBase },
|
||||
rootDir,
|
||||
store,
|
||||
settings: { worktrunk: { enabled: true } } as any,
|
||||
backend: { kind: "worktrunk" } as any,
|
||||
createWorktreeBackendKind: "native",
|
||||
refreshStaleBase: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "existing", baseRefresh: undefined });
|
||||
expect(git(worktreePath, "git rev-parse HEAD")).toBe(staleBase);
|
||||
});
|
||||
|
||||
it("clears a pooled task binding before releasing a worktree that fails base refresh", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const pooledPath = join(rootDir, ".worktrees", "pooled-fn-4-dirty");
|
||||
git(rootDir, `git worktree add -b fusion/fn-4-dirty ${JSON.stringify(pooledPath)}`);
|
||||
writeFileSync(join(pooledPath, "uncommitted.ts"), "dirty\n", "utf-8");
|
||||
const pool = {
|
||||
acquire: vi.fn().mockReturnValue(pooledPath),
|
||||
prepareForTask: vi.fn().mockResolvedValue({
|
||||
branch: "fusion/fn-4-dirty",
|
||||
worktreePath: pooledPath,
|
||||
reclaimed: false,
|
||||
}),
|
||||
release: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await expect(acquireTaskWorktree({
|
||||
task: { ...task, id: "FN-4", branch: "fusion/fn-4-dirty" },
|
||||
rootDir,
|
||||
store,
|
||||
settings: { recycleWorktrees: true },
|
||||
pool,
|
||||
refreshStaleBase: true,
|
||||
})).rejects.toThrow(WorktreeBaseRefreshError);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-4", { worktree: null, branch: null, sessionFile: null });
|
||||
expect(store.updateTask.mock.invocationCallOrder.at(-1)).toBeLessThan(pool.release.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("FN-6861 creates a fresh configured worktree when a resumed assignment points at the repo root", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const actualPool = await vi.importActual<typeof import("../worktree/worktree-pool.js")>("../worktree/worktree-pool.js");
|
||||
|
||||
@@ -10469,6 +10469,9 @@ export class TaskExecutor {
|
||||
runContext: this.getRunContextFor(task.id),
|
||||
runInitCommand: true,
|
||||
createWorktree: this.createWorktree.bind(this),
|
||||
// FNXC:WorktreeAcquisition 2026-08-09-03:30: This injected creator is native even when project settings
|
||||
// prefer Worktrunk; retain its actual backend so stale-base refresh remains enabled on creation and reuse.
|
||||
createWorktreeBackendKind: "native",
|
||||
runConfiguredCommand: (command, cwd, timeoutMs, env) =>
|
||||
runConfiguredCommand(
|
||||
command,
|
||||
@@ -14390,6 +14393,9 @@ export class TaskExecutor {
|
||||
runContext: this.getRunContextFor(task.id),
|
||||
runInitCommand: true,
|
||||
createWorktree: this.createWorktree.bind(this),
|
||||
// FNXC:WorktreeAcquisition 2026-08-09-03:30: This injected creator is native even when project settings
|
||||
// prefer Worktrunk; retain its actual backend so stale-base refresh remains enabled on creation and reuse.
|
||||
createWorktreeBackendKind: "native",
|
||||
runConfiguredCommand: (command, cwd, timeoutMs, env) =>
|
||||
runConfiguredCommand(
|
||||
command,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { exec } from "node:child_process";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {acquireWorktreePathReservation, canonicalizeWorktreePath, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore} from "@fusion/core";
|
||||
import { generateWorktreeName, resolveTaskWorkingBranch, slugify } from "./worktree-names.js";
|
||||
@@ -46,6 +48,29 @@ import { activeSessionRegistry, type ActiveSessionRegistry } from "../agents/act
|
||||
import { refreshReusedWorktreeBase, type WorktreeBaseRefreshResult } from "../worktree-base-refresh.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const WORKTREE_BACKEND_MARKER = "fusion-worktree-backend-kind";
|
||||
|
||||
async function resolveWorktreeBackendMarkerPath(worktreePath: string): Promise<string> {
|
||||
const { stdout } = await execAsync(`git rev-parse --git-path ${JSON.stringify(WORKTREE_BACKEND_MARKER)}`, {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const markerPath = stdout.trim();
|
||||
return isAbsolute(markerPath) ? markerPath : resolve(worktreePath, markerPath);
|
||||
}
|
||||
|
||||
async function persistWorktreeBackendKind(worktreePath: string, backendKind: WorktreeBackend["kind"]): Promise<void> {
|
||||
await writeFile(await resolveWorktreeBackendMarkerPath(worktreePath), `${backendKind}\n`, "utf-8");
|
||||
}
|
||||
|
||||
async function readPersistedWorktreeBackendKind(worktreePath: string): Promise<WorktreeBackend["kind"] | undefined> {
|
||||
try {
|
||||
const backendKind = (await readFile(await resolveWorktreeBackendMarkerPath(worktreePath), "utf-8")).trim();
|
||||
return backendKind === "native" || backendKind === "worktrunk" ? backendKind : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worktree acquisition contract:
|
||||
@@ -71,6 +96,8 @@ export interface AcquireTaskWorktreeOptions {
|
||||
startPoint?: string,
|
||||
allowSiblingBranchRename?: boolean,
|
||||
) => Promise<{ path: string; branch: string }>;
|
||||
/** Actual backend used by an injected creator when it differs from the configured backend. */
|
||||
createWorktreeBackendKind?: WorktreeBackend["kind"];
|
||||
runConfiguredCommand?: (command: string, cwd: string, timeoutMs: number, env?: NodeJS.ProcessEnv) => Promise<{
|
||||
spawnError?: string | Error;
|
||||
timedOut?: boolean;
|
||||
@@ -213,8 +240,11 @@ async function pinnedWorktreeBranchMatches(rootDir: string, worktreePath: string
|
||||
|
||||
export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise<AcquireTaskWorktreeResult> {
|
||||
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts;
|
||||
const refreshExistingWorktree = async (path: string): Promise<WorktreeBaseRefreshResult | undefined> => {
|
||||
if (!opts.refreshStaleBase) return undefined;
|
||||
const refreshExistingWorktree = async (
|
||||
path: string,
|
||||
backendKind: WorktreeBackend["kind"],
|
||||
): Promise<WorktreeBaseRefreshResult | undefined> => {
|
||||
if (!opts.refreshStaleBase || backendKind === "worktrunk") return undefined;
|
||||
/*
|
||||
* FNXC:SecretsEnvMaterialization 2026-08-07-23:13:
|
||||
* Reconcile the v0.75.1 root record before strict porcelain checking. A malformed, conflicting, or
|
||||
@@ -231,11 +261,14 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
}
|
||||
if (!reconciliation.executionSafe) {
|
||||
const refresh: WorktreeBaseRefreshResult = { kind: "base-reconciliation-required", executionSafe: false, detail: reconciliation.outcome };
|
||||
await audit?.git?.({ type: "worktree:base-refresh-blocked", target: task.id, metadata: { taskId: task.id, outcome: refresh.kind, reconciliationOutcome: reconciliation.outcome } });
|
||||
await audit?.git?.({ type: "worktree:base-refresh-blocked", target: path, metadata: { taskId: task.id, outcome: refresh.kind, reconciliationOutcome: reconciliation.outcome } });
|
||||
await store.logEntry(task.id, `Worktree secrets record reconciliation blocked execution (${reconciliation.outcome})`, undefined, runContext);
|
||||
throw new WorktreeBaseRefreshError(refresh);
|
||||
}
|
||||
const refresh = await refreshReusedWorktreeBase({ task, rootDir, worktreePath: path, store, settings, audit, logger });
|
||||
const refreshSettings = settings.worktrunk?.enabled === true
|
||||
? { ...settings, worktrunk: { ...settings.worktrunk, enabled: false } }
|
||||
: settings;
|
||||
const refresh = await refreshReusedWorktreeBase({ task, rootDir, worktreePath: path, store, settings: refreshSettings, audit, logger });
|
||||
if (!refresh.executionSafe) {
|
||||
await audit?.git({ type: refresh.kind === "stale-base-conflict" ? "worktree:base-refresh-conflict" : "worktree:base-refresh-blocked", target: path, metadata: { taskId: task.id, outcome: refresh.kind } });
|
||||
await store.logEntry(task.id, `Worktree base refresh blocked execution (${refresh.kind})`, refresh.detail, runContext);
|
||||
@@ -289,6 +322,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
throw error;
|
||||
}
|
||||
const branchName = resolveTaskWorkingBranch(task);
|
||||
const resolveExistingWorktreeBackendKind = async (path: string): Promise<WorktreeBackend["kind"]> =>
|
||||
(await readPersistedWorktreeBackendKind(path)) ?? opts.createWorktreeBackendKind ?? backend.kind;
|
||||
const naming = settings.worktreeNaming || "random";
|
||||
/*
|
||||
* FNXC:TaskPinnedWorktrees 2026-07-16-00:00:
|
||||
@@ -373,9 +408,17 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
Acquisition delegates branch creation to the isolated-worktree primitive. The project root remains
|
||||
on its current branch; task branch selection must never use a root-checkout `git checkout` or `git switch`.
|
||||
*/
|
||||
const createWorktreeImpl = createWorktree
|
||||
? createWorktree
|
||||
: async (createBranch: string, createPath: string, createTaskId: string, startPoint?: string, allowRename?: boolean) => {
|
||||
const createWorktreeImpl = async (
|
||||
createBranch: string,
|
||||
createPath: string,
|
||||
createTaskId: string,
|
||||
startPoint?: string,
|
||||
allowRename?: boolean,
|
||||
): Promise<{ path: string; branch: string; backendKind: WorktreeBackend["kind"] }> => {
|
||||
if (createWorktree) {
|
||||
const created = await createWorktree(createBranch, createPath, createTaskId, startPoint, allowRename);
|
||||
return { ...created, backendKind: opts.createWorktreeBackendKind ?? backend.kind };
|
||||
}
|
||||
const reservation = await acquireWorktreePathReservation({
|
||||
canonicalPath: await canonicalizeWorktreePath(createPath),
|
||||
worktreesDir: resolveWorktreesDir(rootDir, settings),
|
||||
@@ -413,7 +456,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
metadata: { branch: created.branch },
|
||||
});
|
||||
}
|
||||
return created;
|
||||
await persistWorktreeBackendKind(created.path, backend.kind);
|
||||
return { ...created, backendKind: backend.kind };
|
||||
} catch (error) {
|
||||
if (backend.kind === "worktrunk" && error instanceof WorktrunkOperationError) {
|
||||
// FNXC:WorktreeAcquisition 2026-07-16-00:00: FN-8132 requires native fallback collision dispositions to be audited just like direct native acquisition.
|
||||
@@ -426,7 +470,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
taskId: createTaskId,
|
||||
allowSiblingBranchRename: allowRename,
|
||||
});
|
||||
return await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string };
|
||||
const created = await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string };
|
||||
await persistWorktreeBackendKind(created.path, "native");
|
||||
return { ...created, backendKind: "native" };
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -475,7 +521,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
};
|
||||
|
||||
const finalizeCreatedWorktree = async (
|
||||
created: { path: string; branch: string },
|
||||
created: { path: string; branch: string; backendKind: WorktreeBackend["kind"] },
|
||||
source: "fresh" | "pool",
|
||||
logOrigin: "normal" | "return-guard",
|
||||
): Promise<AcquireTaskWorktreeResult> => {
|
||||
@@ -503,6 +549,11 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
await store.logEntry(task.id, `Worktree created at ${worktreePath}`, undefined, runContext);
|
||||
}
|
||||
|
||||
// FNXC:WorktreeBaseRefresh 2026-08-09-03:30: Execution can recreate an existing task branch after its
|
||||
// dependency branch was merged and deleted. Refresh fresh acquisitions too so that branch cannot resume
|
||||
// from its stale pre-dependency tip.
|
||||
const baseRefresh = await refreshExistingWorktree(worktreePath, created.backendKind);
|
||||
|
||||
const cleanup = await removeDesktopBuildArtifacts(worktreePath, logger);
|
||||
if (cleanup.removed.length > 0) {
|
||||
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
|
||||
@@ -555,7 +606,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
} catch (err) {
|
||||
logger?.warn?.(`${task.id}: secrets-env write failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return { worktreePath, branch, source, hydrated, isResume: false };
|
||||
return { worktreePath, branch, source, hydrated, isResume: false, baseRefresh };
|
||||
};
|
||||
|
||||
const createFreshWorktreeFromReturnGuard = async (guardedPath: string, source: string): Promise<AcquireTaskWorktreeResult> => {
|
||||
@@ -593,7 +644,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
logger,
|
||||
runContext,
|
||||
});
|
||||
const baseRefresh = await refreshExistingWorktree(path);
|
||||
const baseRefresh = await refreshExistingWorktree(path, await resolveExistingWorktreeBackendKind(path));
|
||||
return guardAcquisitionReturn({ worktreePath: path, branch: resumedBranch, source, hydrated, isResume: true, baseRefresh });
|
||||
};
|
||||
|
||||
@@ -719,7 +770,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
runContext,
|
||||
});
|
||||
// FN-4912: resume path reuses the prior on-disk .env (and its fingerprint sidecar). Rewrite is owned by the next fresh acquisition.
|
||||
const baseRefresh = await refreshExistingWorktree(worktreePath);
|
||||
const baseRefresh = await refreshExistingWorktree(worktreePath, await resolveExistingWorktreeBackendKind(worktreePath));
|
||||
return guardAcquisitionReturn({ worktreePath, branch: resumedBranch, source: "existing", hydrated, isResume: true, baseRefresh });
|
||||
}
|
||||
|
||||
@@ -810,6 +861,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
} else {
|
||||
await store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath}`, undefined, runContext);
|
||||
}
|
||||
const baseRefresh = await refreshExistingWorktree(worktreePath, "native");
|
||||
const cleanup = await removeDesktopBuildArtifacts(worktreePath, logger);
|
||||
if (cleanup.removed.length > 0) {
|
||||
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
|
||||
@@ -845,6 +897,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
source: "pool",
|
||||
hydrated,
|
||||
isResume: false,
|
||||
baseRefresh,
|
||||
reclaimed: prepared.reclaimed
|
||||
? {
|
||||
existingTipSha: prepared.existingTipSha,
|
||||
@@ -854,6 +907,13 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
});
|
||||
}
|
||||
} catch (poolErr) {
|
||||
if (poolErr instanceof WorktreeBaseRefreshError) {
|
||||
// FNXC:WorktreeBaseRefresh 2026-08-09-03:30: Clear every durable resume binding before returning the
|
||||
// checkout to the pool. If persistence fails, retain the lease so no other task can mutate it.
|
||||
await store.updateTask(task.id, { worktree: null, branch: null, sessionFile: null });
|
||||
pool.release(pooled, task.id);
|
||||
throw poolErr;
|
||||
}
|
||||
pool.release(pooled, task.id);
|
||||
if (poolErr instanceof PoolDoubleLeaseError) {
|
||||
const poolErrMessage = poolErr instanceof Error ? poolErr.message : String(poolErr);
|
||||
|
||||
Reference in New Issue
Block a user