fix(FN-5279): apply reuse-mode squash to local main and guard executor-lease race
In reuse-task-worktree mode the merger detaches HEAD in the task worktree so the squash commit lands on detached HEAD; nothing previously advanced the project root's local integration branch, so changes never appeared on main. Step 5c now applies the squash to projectRootDir via git merge --ff-only, falling back to a regular merge with AI conflict resolution if main has diverged. pushAfterMerge (when enabled) now runs from projectRootDir where the branch was just advanced, so parsePushRemoteTarget can resolve a branch instead of failing on the worktree's detached HEAD. Also tightens acquireReuseHandoff: the executor-lease check above the queue-lease acquisition was non-atomic, letting a local executor grab the task between checks. Re-check after acquisition and release the queue lease with a precise diagnostic instead of proceeding into a generic failure later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-fn-5279-reuse-mode-push.md
Normal file
5
.changeset/fix-fn-5279-reuse-mode-push.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix reuse-task-worktree merge mode (FN-5279) never applying the squash commit to the project root's local integration branch. The merger detaches HEAD in the task worktree and lands the squash on the detached HEAD; previously nothing advanced the project root's local `main`, so changes never appeared on the user's `main` (and any subsequent `pushAfterMerge` would push the stale ref or fail outright because `parsePushRemoteTarget` can't resolve a branch from a detached HEAD). A new step 5c now applies the squash to the project root's integration branch via `git merge --ff-only`, falling back to a regular merge with AI conflict resolution if `main` has diverged. Push-after-merge (when enabled) now runs from the project root where the integration branch was just advanced.
|
||||||
@@ -22,7 +22,7 @@ import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
|||||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||||
|
|
||||||
describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||||
it.skipIf(!hasGit)("happy path merges from a reused task worktree without mutating the project root", async () => {
|
it.skipIf(!hasGit)("happy path merges from a reused task worktree and applies the squash to the project root's integration branch", async () => {
|
||||||
const fixture = await makeReliabilityFixture({
|
const fixture = await makeReliabilityFixture({
|
||||||
taskId: "FN-5279-RI-HAPPY",
|
taskId: "FN-5279-RI-HAPPY",
|
||||||
settings: {
|
settings: {
|
||||||
@@ -71,7 +71,14 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
|||||||
const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired");
|
const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired");
|
||||||
expect(acquired?.metadata).toMatchObject({ integrationRemote: "origin", integrationBranch: "master" });
|
expect(acquired?.metadata).toMatchObject({ integrationRemote: "origin", integrationBranch: "master" });
|
||||||
|
|
||||||
expect(git(rootDir, "git rev-parse HEAD")).toBe(rootHeadBefore);
|
// Step 5c (FN-5279 reuse mode) advances the project root's integration
|
||||||
|
// branch to the new squash commit so changes actually land on master.
|
||||||
|
expect(auditTypes).toContain("merge:reuse-integration-branch-advanced");
|
||||||
|
const advanced = audits.find(
|
||||||
|
(event) => event.mutationType === "merge:reuse-integration-branch-advanced",
|
||||||
|
);
|
||||||
|
expect(advanced?.metadata).toMatchObject({ via: "ff-merge" });
|
||||||
|
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
|
||||||
expect(git(rootDir, "git status --porcelain --untracked-files=no")).toBe(rootTrackedStatusBefore);
|
expect(git(rootDir, "git status --porcelain --untracked-files=no")).toBe(rootTrackedStatusBefore);
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.cleanup();
|
await fixture.cleanup();
|
||||||
|
|||||||
@@ -420,6 +420,24 @@ export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<Han
|
|||||||
acquiredTaskId: lease && "taskId" in lease ? lease.taskId : null,
|
acquiredTaskId: lease && "taskId" in lease ? lease.taskId : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Re-check executor lease after acquiring the merge-queue lease: the
|
||||||
|
// checks above (lines ~362–391) are non-atomic with acquisition, so a
|
||||||
|
// local executor could grab the task between them. Releasing here gives
|
||||||
|
// a precise diagnostic instead of letting the merge proceed with a
|
||||||
|
// conflicting executor lease and surfacing as a generic failure later.
|
||||||
|
if (executingTaskLock.has(input.task.id)) {
|
||||||
|
(input.store as TaskStore & {
|
||||||
|
releaseMergeQueueLease(taskId: string, workerId: string, outcome: MergeQueueReleaseOutcome): void;
|
||||||
|
}).releaseMergeQueueLease(input.task.id, MERGE_HANDOFF_WORKER_ID, {
|
||||||
|
kind: "failure",
|
||||||
|
error: "executor-lease-acquired-after-queue-lease",
|
||||||
|
});
|
||||||
|
throw new MergeHandoffRefusedError("lease-handoff-failed", "executor-lease-race-detected", {
|
||||||
|
taskId: input.task.id,
|
||||||
|
worktreePath,
|
||||||
|
reason: "executor_lease_acquired_after_queue_lease",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -8400,6 +8400,142 @@ export async function aiMergeTask(
|
|||||||
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5c. Apply squash commit to the project root's local integration branch
|
||||||
|
// (FN-5279). In reuse-task-worktree mode step 3b detached HEAD in the task
|
||||||
|
// worktree, so the squash commit landed on a detached HEAD and the local
|
||||||
|
// integration branch ref in the project root still points at the pre-merge
|
||||||
|
// tip. Advance it now via `git merge --ff-only` so the changes are
|
||||||
|
// actually applied to local main. If main has diverged in the meantime
|
||||||
|
// (rare — merge queue lease should prevent concurrent advances), fall back
|
||||||
|
// to a regular merge and let the existing AI conflict resolution helper
|
||||||
|
// resolve any conflicts.
|
||||||
|
if (reuseTaskWorktreeMerge) {
|
||||||
|
try {
|
||||||
|
const worktreeHeadSha = execSyncText("git rev-parse HEAD", {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
if (worktreeHeadSha) {
|
||||||
|
const integrationBranch = mergeTarget.branch;
|
||||||
|
try {
|
||||||
|
await execAsync(`git merge --ff-only ${quoteArg(worktreeHeadSha)}`, {
|
||||||
|
cwd: projectRootDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await (audit as any).git({
|
||||||
|
type: "merge:reuse-integration-branch-advanced",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: { taskId, sha: worktreeHeadSha, via: "ff-merge", projectRootDir },
|
||||||
|
});
|
||||||
|
mergerLog.log(
|
||||||
|
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root (ff-merge)`,
|
||||||
|
);
|
||||||
|
} catch (ffErr: unknown) {
|
||||||
|
const ffMsg = ffErr instanceof Error ? ffErr.message : String(ffErr);
|
||||||
|
mergerLog.warn(
|
||||||
|
`${taskId}: ff-merge of ${worktreeHeadSha.slice(0, 8)} into ${integrationBranch} failed (${ffMsg}); attempting non-ff merge with AI conflict resolution`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await execAsync(`git merge --no-edit ${quoteArg(worktreeHeadSha)}`, {
|
||||||
|
cwd: projectRootDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
await (audit as any).git({
|
||||||
|
type: "merge:reuse-integration-branch-advanced",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: { taskId, sha: worktreeHeadSha, via: "non-ff-merge", projectRootDir },
|
||||||
|
});
|
||||||
|
mergerLog.log(
|
||||||
|
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root (non-ff merge, no conflicts)`,
|
||||||
|
);
|
||||||
|
} catch (mergeErr: unknown) {
|
||||||
|
const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
||||||
|
const conflicted = await getConflictedFiles(projectRootDir).catch(() => [] as string[]);
|
||||||
|
if (conflicted.length === 0) {
|
||||||
|
await execAsync("git merge --abort", { cwd: projectRootDir, timeout: 30_000 }).catch(() => undefined);
|
||||||
|
throw new Error(
|
||||||
|
`Non-ff merge of ${worktreeHeadSha} into ${integrationBranch} failed without conflict markers: ${mergeMsg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mergerLog.log(
|
||||||
|
`${taskId}: ${conflicted.length} conflict(s) applying squash to ${integrationBranch} — invoking AI conflict resolution`,
|
||||||
|
);
|
||||||
|
const conflictAssignedAgentId = task.assignedAgentId?.trim();
|
||||||
|
const conflictAgentStoreWithGetAgent = options.agentStore
|
||||||
|
&& typeof (options.agentStore as { getAgent?: unknown }).getAgent === "function"
|
||||||
|
? options.agentStore
|
||||||
|
: null;
|
||||||
|
const conflictAssignedAgent = conflictAssignedAgentId && conflictAgentStoreWithGetAgent
|
||||||
|
? await (conflictAgentStoreWithGetAgent as any).getAgent(conflictAssignedAgentId).catch(() => null)
|
||||||
|
: null;
|
||||||
|
const conflictRuntimeHint = extractRuntimeHint(conflictAssignedAgent?.runtimeConfig);
|
||||||
|
await resolveComplexRebaseConflictsWithAi(
|
||||||
|
store,
|
||||||
|
projectRootDir,
|
||||||
|
taskId,
|
||||||
|
settings,
|
||||||
|
conflicted,
|
||||||
|
{
|
||||||
|
onAgentText: options.onAgentText,
|
||||||
|
signal: options.signal,
|
||||||
|
runtimeHint: conflictRuntimeHint,
|
||||||
|
assignedAgentRuntimeConfig: conflictAssignedAgent?.runtimeConfig,
|
||||||
|
onSession: options.onSession,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const stillConflicted = await getConflictedFiles(projectRootDir).catch(() => [] as string[]);
|
||||||
|
if (stillConflicted.length > 0) {
|
||||||
|
await execAsync("git merge --abort", { cwd: projectRootDir, timeout: 30_000 }).catch(() => undefined);
|
||||||
|
throw new Error(
|
||||||
|
`AI conflict resolution left ${stillConflicted.length} unresolved file(s) when applying squash to ${integrationBranch}: ${stillConflicted.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await execAsync(`git commit --no-edit`, {
|
||||||
|
cwd: projectRootDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await (audit as any).git({
|
||||||
|
type: "merge:reuse-integration-branch-advanced",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: {
|
||||||
|
taskId,
|
||||||
|
sha: worktreeHeadSha,
|
||||||
|
via: "non-ff-merge-ai-resolved",
|
||||||
|
projectRootDir,
|
||||||
|
conflictedFiles: conflicted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mergerLog.log(
|
||||||
|
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root after AI conflict resolution (${conflicted.length} file(s))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (advErr: unknown) {
|
||||||
|
const advMsg = advErr instanceof Error ? advErr.message : String(advErr);
|
||||||
|
mergerLog.error(
|
||||||
|
`${taskId}: failed to apply squash to ${mergeTarget.branch} in project root: ${advMsg}`,
|
||||||
|
);
|
||||||
|
await (audit as any).git({
|
||||||
|
type: "merge:reuse-integration-branch-advance-failed",
|
||||||
|
target: mergeTarget.branch,
|
||||||
|
metadata: { taskId, error: advMsg, projectRootDir },
|
||||||
|
});
|
||||||
|
// Abort: leaving reuseTaskWorktreeMerge=true would cause the subsequent
|
||||||
|
// push step to operate on projectRootDir where the squash was never
|
||||||
|
// applied, shipping the pre-merge ref. Mark the reuse-merge as failed
|
||||||
|
// and surface the error so the merge can be retried cleanly.
|
||||||
|
reuseTaskWorktreeMerge = false;
|
||||||
|
throw new Error(
|
||||||
|
`Failed to advance ${mergeTarget.branch} in project root after reuse-task-worktree squash: ${advMsg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Delete branch
|
// 6. Delete branch
|
||||||
try {
|
try {
|
||||||
await execAsync(`git branch -d "${branch}"`, { cwd: rootDir });
|
await execAsync(`git branch -d "${branch}"`, { cwd: rootDir });
|
||||||
@@ -8538,7 +8674,12 @@ export async function aiMergeTask(
|
|||||||
? await pushAgentStoreWithGetAgent.getAgent(pushAssignedAgentId).catch(() => null)
|
? await pushAgentStoreWithGetAgent.getAgent(pushAssignedAgentId).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
const pushRuntimeHint = extractRuntimeHint(pushAssignedAgent?.runtimeConfig);
|
const pushRuntimeHint = extractRuntimeHint(pushAssignedAgent?.runtimeConfig);
|
||||||
const pushResult = await pushToRemoteAfterMerge(store, rootDir, taskId, settings, {
|
// In reuse-task-worktree mode, rootDir is the task worktree with a
|
||||||
|
// detached HEAD; step 5c already advanced the project root's
|
||||||
|
// integration branch to the new squash commit, so push from
|
||||||
|
// projectRootDir where the branch is checked out and at the new tip.
|
||||||
|
const pushRootDir = reuseTaskWorktreeMerge ? projectRootDir : rootDir;
|
||||||
|
const pushResult = await pushToRemoteAfterMerge(store, pushRootDir, taskId, settings, {
|
||||||
onAgentText: options.onAgentText,
|
onAgentText: options.onAgentText,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
runtimeHint: pushRuntimeHint,
|
runtimeHint: pushRuntimeHint,
|
||||||
@@ -8552,7 +8693,7 @@ export async function aiMergeTask(
|
|||||||
// mergeDetails / recovery don't reference a now-orphaned commit.
|
// mergeDetails / recovery don't reference a now-orphaned commit.
|
||||||
try {
|
try {
|
||||||
const postPushSha = execSync("git rev-parse HEAD", {
|
const postPushSha = execSync("git rev-parse HEAD", {
|
||||||
cwd: rootDir,
|
cwd: pushRootDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
}).trim() || undefined;
|
}).trim() || undefined;
|
||||||
@@ -8568,7 +8709,7 @@ export async function aiMergeTask(
|
|||||||
try {
|
try {
|
||||||
const { stdout: postPushStatsOutput } = await execAsync(
|
const { stdout: postPushStatsOutput } = await execAsync(
|
||||||
`git show --shortstat --format= ${quoteArg(postPushSha)}`,
|
`git show --shortstat --format= ${quoteArg(postPushSha)}`,
|
||||||
{ cwd: rootDir, encoding: "utf-8" },
|
{ cwd: pushRootDir, encoding: "utf-8" },
|
||||||
);
|
);
|
||||||
const normalized = postPushStatsOutput.trim().replace(/\n/g, " ");
|
const normalized = postPushStatsOutput.trim().replace(/\n/g, " ");
|
||||||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||||||
|
|||||||
Reference in New Issue
Block a user