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:
@@ -22,7 +22,7 @@ import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
|
||||
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({
|
||||
taskId: "FN-5279-RI-HAPPY",
|
||||
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");
|
||||
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);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
|
||||
@@ -420,6 +420,24 @@ export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<Han
|
||||
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 {
|
||||
ok: true,
|
||||
|
||||
@@ -8400,6 +8400,142 @@ export async function aiMergeTask(
|
||||
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
|
||||
try {
|
||||
await execAsync(`git branch -d "${branch}"`, { cwd: rootDir });
|
||||
@@ -8538,7 +8674,12 @@ export async function aiMergeTask(
|
||||
? await pushAgentStoreWithGetAgent.getAgent(pushAssignedAgentId).catch(() => null)
|
||||
: null;
|
||||
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,
|
||||
signal: options.signal,
|
||||
runtimeHint: pushRuntimeHint,
|
||||
@@ -8552,7 +8693,7 @@ export async function aiMergeTask(
|
||||
// mergeDetails / recovery don't reference a now-orphaned commit.
|
||||
try {
|
||||
const postPushSha = execSync("git rev-parse HEAD", {
|
||||
cwd: rootDir,
|
||||
cwd: pushRootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim() || undefined;
|
||||
@@ -8568,7 +8709,7 @@ export async function aiMergeTask(
|
||||
try {
|
||||
const { stdout: postPushStatsOutput } = await execAsync(
|
||||
`git show --shortstat --format= ${quoteArg(postPushSha)}`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
{ cwd: pushRootDir, encoding: "utf-8" },
|
||||
);
|
||||
const normalized = postPushStatsOutput.trim().replace(/\n/g, " ");
|
||||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||||
|
||||
Reference in New Issue
Block a user