fix(FN-5353): reacquire fresh worktree on reuse-handoff refusal
After merge:reuse-handoff-refused with no usable task worktree:
- Acquire a fresh worktree and restore the fusion/<task-id> branch
from baseCommitSha before continuing merge
- Hard-fail only if fresh acquisition itself fails (not cwd-main fallback)
- Emit merge:reuse-worktree-fresh-acquire / merge:reuse-worktree-fresh-acquired
audit events around the acquisition lifecycle
Replaces the incorrect cwd-main fallback (499784581) with correct
reacquire path. Updates regression test in merge-reuse-task-worktree.test.ts
to assert fresh-acquire audit trail instead of cwd-main fallback.
Refs: FN-5353
This commit is contained in:
@@ -349,7 +349,7 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
|||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it.skipIf(!hasGit)("falls back to cwd-main when reuse is requested without a task worktree", async () => {
|
it.skipIf(!hasGit)("reacquires a fresh task worktree when reuse is requested without a task worktree", async () => {
|
||||||
const fixture = await makeReliabilityFixture({
|
const fixture = await makeReliabilityFixture({
|
||||||
taskId: "FN-5353-RI-MISSING-WORKTREE",
|
taskId: "FN-5353-RI-MISSING-WORKTREE",
|
||||||
settings: {
|
settings: {
|
||||||
@@ -382,12 +382,13 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
|||||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||||
const audits = store.getRunAuditEvents({ taskId: task.id });
|
const audits = store.getRunAuditEvents({ taskId: task.id });
|
||||||
const auditTypes = audits.map((event) => event.mutationType);
|
const auditTypes = audits.map((event) => event.mutationType);
|
||||||
expect(auditTypes).toContain("merge:reuse-fallback-cwd-main");
|
expect(auditTypes).toContain("merge:reuse-fallback-new-worktree");
|
||||||
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
|
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||||
const fallback = audits.find((event) => event.mutationType === "merge:reuse-fallback-cwd-main");
|
expect(auditTypes).not.toContain("merge:reuse-fallback-cwd-main");
|
||||||
|
const fallback = audits.find((event) => event.mutationType === "merge:reuse-fallback-new-worktree");
|
||||||
expect(fallback?.metadata).toMatchObject({
|
expect(fallback?.metadata).toMatchObject({
|
||||||
reason: "missing-task-worktree",
|
reason: "missing-task-worktree",
|
||||||
worktreePath: null,
|
source: "fresh",
|
||||||
integrationBranch: "master",
|
integrationBranch: "master",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6491,97 +6491,18 @@ export async function aiMergeTask(
|
|||||||
|
|
||||||
let reuseHandoff: HandoffResult | undefined;
|
let reuseHandoff: HandoffResult | undefined;
|
||||||
if (integrationRoot.mode === "reuse-task-worktree") {
|
if (integrationRoot.mode === "reuse-task-worktree") {
|
||||||
// Check if the task has a usable worktree synchronously (two fast-path checks: dir exists + .git marker).
|
|
||||||
const reusableWorktreePath = task.worktree?.trim();
|
const reusableWorktreePath = task.worktree?.trim();
|
||||||
const isWorktreeUnusable = !reusableWorktreePath
|
if (!reusableWorktreePath) {
|
||||||
|| !existsSync(reusableWorktreePath)
|
await reacquireReuseIntegrationWorktree("missing-task-worktree", {
|
||||||
|| !existsSync(join(reusableWorktreePath, ".git"));
|
requestedMode: requestedIntegrationMode,
|
||||||
if (isWorktreeUnusable) {
|
});
|
||||||
// Task has no/reusable worktree. Acquire/recreate a fresh one inline.
|
|
||||||
await emitReuseHandoffAuditEvent(
|
|
||||||
"merge:reuse-worktree-fresh-acquire",
|
|
||||||
{ taskId, priorWorktreePath: task.worktree ?? null, reason: !reusableWorktreePath ? "missing-task-worktree" : "unusable-task-worktree" },
|
|
||||||
projectRootDir,
|
|
||||||
);
|
|
||||||
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, rootDir: newWorktreePath };
|
|
||||||
rootDir = newWorktreePath;
|
|
||||||
} catch (acquireErr: unknown) {
|
|
||||||
const msg = acquireErr instanceof Error ? acquireErr.message : String(acquireErr);
|
|
||||||
mergerLog.warn(`${taskId}: fresh worktree acquisition 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 },
|
|
||||||
projectRootDir,
|
|
||||||
);
|
|
||||||
integrationRoot = { ...integrationRoot, mode: "cwd-main", rootDir: projectRootDir };
|
|
||||||
reuseTaskWorktreeMerge = false;
|
|
||||||
rootDir = projectRootDir;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
|
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
|
||||||
if (!classification.ok) {
|
if (!classification.ok) {
|
||||||
// Worktree path exists but fails async classification. Use fresh acquisition.
|
await reacquireReuseIntegrationWorktree("unusable-task-worktree", {
|
||||||
await emitReuseHandoffAuditEvent(
|
requestedMode: requestedIntegrationMode,
|
||||||
"merge:reuse-worktree-fresh-acquire",
|
classification,
|
||||||
{ taskId, priorWorktreePath: reusableWorktreePath, reason: "unusable-task-worktree", classification },
|
});
|
||||||
projectRootDir,
|
|
||||||
);
|
|
||||||
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, rootDir: newWorktreePath };
|
|
||||||
rootDir = newWorktreePath;
|
|
||||||
} catch (acquireErr: unknown) {
|
|
||||||
const msg = acquireErr instanceof Error ? acquireErr.message : String(acquireErr);
|
|
||||||
mergerLog.warn(`${taskId}: fresh worktree acquisition failed (${msg}) — falling back to cwd-main`);
|
|
||||||
await emitReuseHandoffAuditEvent(
|
|
||||||
"merge:reuse-fallback-cwd-main",
|
|
||||||
{ taskId, reason: "fresh-worktree-acquisition-failed", priorWorktreePath: reusableWorktreePath, acquisitionError: msg },
|
|
||||||
projectRootDir,
|
|
||||||
);
|
|
||||||
integrationRoot = { ...integrationRoot, mode: "cwd-main", rootDir: projectRootDir };
|
|
||||||
reuseTaskWorktreeMerge = false;
|
|
||||||
rootDir = projectRootDir;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6621,24 +6542,57 @@ export async function aiMergeTask(
|
|||||||
integrationRoot.rootDir,
|
integrationRoot.rootDir,
|
||||||
);
|
);
|
||||||
const reusableWorktreePath = task.worktree?.trim();
|
const reusableWorktreePath = task.worktree?.trim();
|
||||||
if (!reusableWorktreePath) {
|
// Check if the worktree is usable synchronously.
|
||||||
await reacquireReuseIntegrationWorktree("missing-task-worktree-after-refusal", {
|
// If usable, the refusal is a genuine liveness conflict — re-throw.
|
||||||
requestedMode: requestedIntegrationMode,
|
// If unusable, try fresh acquisition.
|
||||||
gate: error.gate,
|
const isWorktreeUnusable = !reusableWorktreePath
|
||||||
reason: error.reason,
|
|| !existsSync(reusableWorktreePath)
|
||||||
});
|
|| !existsSync(join(reusableWorktreePath, ".git"));
|
||||||
} else {
|
if (isWorktreeUnusable) {
|
||||||
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
|
await emitReuseHandoffAuditEvent(
|
||||||
if (!classification.ok) {
|
"merge:reuse-worktree-fresh-acquire",
|
||||||
await reacquireReuseIntegrationWorktree("unusable-task-worktree-after-refusal", {
|
{ taskId, gate: error.gate, reason: error.reason, priorWorktreePath: task.worktree ?? null },
|
||||||
requestedMode: requestedIntegrationMode,
|
integrationRoot.rootDir,
|
||||||
gate: error.gate,
|
);
|
||||||
reason: error.reason,
|
const worktreeName = generateWorktreeName(projectRootDir, settings);
|
||||||
classification,
|
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],
|
||||||
});
|
});
|
||||||
} else {
|
await store.updateTask(taskId, { worktree: newWorktreePath, branch: branchName });
|
||||||
throw error;
|
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;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Worktree path exists and is usable — genuine liveness/lease conflict. Re-throw.
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export type GitMutationType =
|
|||||||
| "merge:reuse-handoff-released"
|
| "merge:reuse-handoff-released"
|
||||||
| "merge:reuse-handoff-deferred-to-worktrunk"
|
| "merge:reuse-handoff-deferred-to-worktrunk"
|
||||||
| "merge:reuse-fallback-cwd-main"
|
| "merge:reuse-fallback-cwd-main"
|
||||||
|
| "merge:reuse-fallback-new-worktree"
|
||||||
| "merge:audit-failure"
|
| "merge:audit-failure"
|
||||||
| "branch:auto-reclaim"
|
| "branch:auto-reclaim"
|
||||||
| "branch:auto-canonicalize-case"
|
| "branch:auto-canonicalize-case"
|
||||||
|
|||||||
Reference in New Issue
Block a user