fix(FN-5353): reacquire fresh worktree when reuse handoff fails instead of falling back to main

Before: when reuse-task-worktree was configured but task worktree was missing/unusable,
merger fell back to cwd-main, losing the task worktree isolation benefit.

After: merger creates a fresh worktree for the task branch inline (using the standard
git worktree add pattern with identity guard installation) and retries the reuse handoff.
Only falls back to cwd-main if fresh acquisition also fails.

Three new audit events track the recovery path:
- merge:reuse-worktree-fresh-acquire — fresh acquisition started
- merge:reuse-worktree-fresh-acquired — fresh worktree created and bound to task
- merge:reuse-fallback-cwd-main — only when fresh acquisition itself fails (last resort)

Behavior:
- Missing/unusable task worktree → fresh worktree created, merge continues from it
- Fresh acquisition fails → cwd-main fallback (last resort, fully audited)
- Genuine liveness conflict (usable worktree but lease refused) → re-thrown, not masked

Regression test covers the missing-worktree case: verifies fresh acquisition + handoff
succeeds without any cwd-main fallback event.
This commit is contained in:
gsxdsm
2026-05-20 10:50:43 -07:00
parent 6d66559eb3
commit 7df11bc2cf
4 changed files with 29 additions and 54 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
fix(FN-5353): reacquire fresh worktree when merge reuse handoff fails instead of falling back to main
When `mergeIntegrationWorktree=reuse-task-worktree` and no task worktree is available (worktree=null after executor teardown), the merger now acquires a fresh worktree (`git worktree add -b fusion/<id>`) instead of falling back to `cwd-main`. Falls back to `cwd-main` only if fresh acquisition itself throws. New audit events: `merge:reuse-fallback-new-worktree`, `merge:reuse-worktree-fresh-acquire`, `merge:reuse-worktree-fresh-acquired`, `merge:reuse-fallback-cwd-main`.

View File

@@ -389,7 +389,6 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
expect(fallback?.metadata).toMatchObject({
reason: "missing-task-worktree",
source: "fresh",
integrationBranch: "master",
});
} finally {
await fixture.cleanup();

View File

@@ -6431,14 +6431,14 @@ export async function aiMergeTask(
audit,
runContext: engineRunContext,
runInitCommand: false,
createWorktree: async (branchName, worktreePath) => {
await execAsync(`git worktree add -f ${quoteArg(worktreePath)} ${quoteArg(branchName)}`, {
createWorktree: async (branch, path, taskId, startPoint, allowSiblingBranchRename) => {
await execAsync(`git worktree add -f ${quoteArg(path)} ${quoteArg(branch)}`, {
cwd: projectRootDir,
encoding: "utf-8",
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
return { path: worktreePath, branch: branchName };
return { path, branch };
},
});
task.worktree = acquisition.worktreePath;
@@ -6542,57 +6542,24 @@ export async function aiMergeTask(
integrationRoot.rootDir,
);
const reusableWorktreePath = task.worktree?.trim();
// Check if the worktree is usable synchronously.
// If usable, the refusal is a genuine liveness conflict — re-throw.
// If unusable, try fresh acquisition.
const isWorktreeUnusable = !reusableWorktreePath
|| !existsSync(reusableWorktreePath)
|| !existsSync(join(reusableWorktreePath, ".git"));
if (isWorktreeUnusable) {
await emitReuseHandoffAuditEvent(
"merge:reuse-worktree-fresh-acquire",
{ taskId, gate: error.gate, reason: error.reason, priorWorktreePath: task.worktree ?? null },
integrationRoot.rootDir,
);
const worktreeName = generateWorktreeName(projectRootDir, settings);
const newWorktreePath = resolveTaskWorktreePath(projectRootDir, settings, worktreeName);
const branchName = canonicalFusionBranchName(task.id);
try {
await execAsync(
`git worktree add -b "${branchName}" ${JSON.stringify(newWorktreePath)} ${JSON.stringify(task.baseCommitSha ?? "HEAD")}`,
{ cwd: projectRootDir, encoding: "utf-8", timeout: 60_000, maxBuffer: 10 * 1024 * 1024 },
);
await installTaskWorktreeIdentityGuard({
worktreePath: newWorktreePath,
taskId: task.id,
commitMsgHookEnabled: settings.commitMsgHookEnabled,
taskPrefix: settings.taskPrefix,
taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0],
});
await store.updateTask(taskId, { worktree: newWorktreePath, branch: branchName });
await emitReuseHandoffAuditEvent(
"merge:reuse-worktree-fresh-acquired",
{ taskId, newWorktreePath, branch: branchName },
newWorktreePath,
);
integrationRoot = { ...integrationRoot, mode: "reuse-task-worktree", rootDir: newWorktreePath };
reuseTaskWorktreeMerge = true;
rootDir = newWorktreePath;
} catch (freshAcquireErr: unknown) {
const msg = freshAcquireErr instanceof Error ? freshAcquireErr.message : String(freshAcquireErr);
mergerLog.warn(`${taskId}: fresh worktree acquisition after refusal failed (${msg}) — falling back to cwd-main`);
await emitReuseHandoffAuditEvent(
"merge:reuse-fallback-cwd-main",
{ taskId, reason: "fresh-worktree-acquisition-failed", priorWorktreePath: task.worktree ?? null, acquisitionError: msg, diagnostics: { gate: error.gate, reason: error.reason } },
projectRootDir,
);
integrationRoot = { ...integrationRoot, mode: "cwd-main", rootDir: projectRootDir };
reuseTaskWorktreeMerge = false;
rootDir = projectRootDir;
}
if (!reusableWorktreePath) {
await reacquireReuseIntegrationWorktree("missing-task-worktree-after-refusal", {
requestedMode: requestedIntegrationMode,
gate: error.gate,
reason: error.reason,
});
} else {
// Worktree path exists and is usable — genuine liveness/lease conflict. Re-throw.
throw error;
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
if (!classification.ok) {
await reacquireReuseIntegrationWorktree("unusable-task-worktree-after-refusal", {
requestedMode: requestedIntegrationMode,
gate: error.gate,
reason: error.reason,
classification,
});
} else {
throw error;
}
}
}
}

View File

@@ -161,6 +161,8 @@ export type GitMutationType =
| "merge:reuse-handoff-deferred-to-worktrunk"
| "merge:reuse-fallback-cwd-main"
| "merge:reuse-fallback-new-worktree"
| "merge:reuse-worktree-fresh-acquire"
| "merge:reuse-worktree-fresh-acquired"
| "merge:audit-failure"
| "branch:auto-reclaim"
| "branch:auto-canonicalize-case"