fix(FN-5353): fallback merge reuse handoff to cwd-main

This commit is contained in:
gsxdsm
2026-05-20 10:32:59 -07:00
parent 13b564b0f6
commit 4997845812
3 changed files with 128 additions and 15 deletions

View File

@@ -349,6 +349,52 @@ 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 () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5353-RI-MISSING-WORKTREE",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
worktree: null,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5353-ri-missing-worktree.ts", "export const fallback = true;\n", "feat: add fallback merge content");
await fixture.checkout("master");
store.enqueueMergeQueue(task.id);
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
expect((await store.getTask(task.id))?.column).toBe("done");
const audits = store.getRunAuditEvents({ taskId: task.id });
const auditTypes = audits.map((event) => event.mutationType);
expect(auditTypes).toContain("merge:reuse-fallback-cwd-main");
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
const fallback = audits.find((event) => event.mutationType === "merge:reuse-fallback-cwd-main");
expect(fallback?.metadata).toMatchObject({
reason: "missing-task-worktree",
worktreePath: null,
integrationBranch: "master",
});
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("cwd-main mode stays on the legacy path and emits no reuse handoff events", async () => { it.skipIf(!hasGit)("cwd-main mode stays on the legacy path and emits no reuse handoff events", async () => {
const fixture = await makeReliabilityFixture({ const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-CWD-MAIN", taskId: "FN-5279-RI-CWD-MAIN",

View File

@@ -74,7 +74,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js"; import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { buildSessionSkillContext } from "./session-skill-context.js"; import { buildSessionSkillContext } from "./session-skill-context.js";
import { RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js"; import { classifyTaskWorktree, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
import { activeSessionRegistry } from "./active-session-registry.js"; import { activeSessionRegistry } from "./active-session-registry.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js"; import { mergerLog } from "./logger.js";
@@ -6386,6 +6386,7 @@ export async function aiMergeTask(
| "merge:reuse-handoff-refused" | "merge:reuse-handoff-refused"
| "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"
| "branch:auto-canonicalize-case", | "branch:auto-canonicalize-case",
metadata: Record<string, unknown>, metadata: Record<string, unknown>,
target: string, target: string,
@@ -6402,18 +6403,47 @@ export async function aiMergeTask(
const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main" const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main"
? "cwd-main" ? "cwd-main"
: "reuse-task-worktree"; : "reuse-task-worktree";
const integrationRoot = resolveMergeIntegrationRoot({ let integrationRoot = resolveMergeIntegrationRoot({
task, task,
settings, settings,
projectRoot: projectRootDir, projectRoot: projectRootDir,
}); });
const reuseTaskWorktreeMerge = integrationRoot.mode === "reuse-task-worktree"; let reuseTaskWorktreeMerge = integrationRoot.mode === "reuse-task-worktree";
rootDir = integrationRoot.rootDir; rootDir = integrationRoot.rootDir;
const integrationRemote = await resolveIntegrationRemote({ let integrationRemote = await resolveIntegrationRemote({
settings, settings,
rootDir: rootDir, rootDir: rootDir,
integrationBranch: mergeTarget.branch, integrationBranch: mergeTarget.branch,
}); });
const fallbackReuseIntegrationToCwdMain = async (
reason: string,
diagnostics: Record<string, unknown>,
): Promise<void> => {
integrationRoot = {
...integrationRoot,
mode: "cwd-main",
rootDir: projectRootDir,
};
reuseTaskWorktreeMerge = false;
rootDir = projectRootDir;
integrationRemote = await resolveIntegrationRemote({
settings,
rootDir,
integrationBranch: mergeTarget.branch,
});
await emitReuseHandoffAuditEvent(
"merge:reuse-fallback-cwd-main",
{
taskId,
reason,
worktreePath: task.worktree ?? null,
diagnostics,
integrationRemote: integrationRemote ?? null,
integrationBranch: mergeTarget.branch,
},
rootDir,
);
};
if ( if (
settings.worktrunk?.enabled === true settings.worktrunk?.enabled === true
&& requestedIntegrationMode === "reuse-task-worktree" && requestedIntegrationMode === "reuse-task-worktree"
@@ -6432,6 +6462,22 @@ export async function aiMergeTask(
} }
let reuseHandoff: HandoffResult | undefined; let reuseHandoff: HandoffResult | undefined;
if (integrationRoot.mode === "reuse-task-worktree") {
const reusableWorktreePath = task.worktree?.trim();
if (!reusableWorktreePath) {
await fallbackReuseIntegrationToCwdMain("missing-task-worktree", {
requestedMode: requestedIntegrationMode,
});
} else {
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
if (!classification.ok) {
await fallbackReuseIntegrationToCwdMain("unusable-task-worktree", {
requestedMode: requestedIntegrationMode,
classification,
});
}
}
}
if (integrationRoot.mode === "reuse-task-worktree") { if (integrationRoot.mode === "reuse-task-worktree") {
try { try {
reuseHandoff = await acquireReuseHandoff({ reuseHandoff = await acquireReuseHandoff({
@@ -6453,8 +6499,10 @@ export async function aiMergeTask(
}, },
reuseHandoff.worktreePath, reuseHandoff.worktreePath,
); );
} catch (error) { } catch (error: unknown) {
if (error instanceof MergeHandoffRefusedError) { if (!(error instanceof MergeHandoffRefusedError)) {
throw error;
}
await emitReuseHandoffAuditEvent( await emitReuseHandoffAuditEvent(
"merge:reuse-handoff-refused", "merge:reuse-handoff-refused",
{ {
@@ -6465,10 +6513,28 @@ export async function aiMergeTask(
}, },
integrationRoot.rootDir, integrationRoot.rootDir,
); );
} const reusableWorktreePath = task.worktree?.trim();
if (!reusableWorktreePath) {
await fallbackReuseIntegrationToCwdMain("missing-task-worktree-after-refusal", {
requestedMode: requestedIntegrationMode,
gate: error.gate,
reason: error.reason,
});
} else {
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
if (!classification.ok) {
await fallbackReuseIntegrationToCwdMain("unusable-task-worktree-after-refusal", {
requestedMode: requestedIntegrationMode,
gate: error.gate,
reason: error.reason,
classification,
});
} else {
throw error; throw error;
} }
} }
}
}
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch?.trim() || mergeTarget.branch; const requestedBaseRef = task.mergeDetails?.mergeTargetBranch?.trim() || mergeTarget.branch;
const releaseReuseHandoffEarly = async (outcome: string): Promise<void> => { const releaseReuseHandoffEarly = async (outcome: string): Promise<void> => {

View File

@@ -159,6 +159,7 @@ export type GitMutationType =
| "merge:reuse-handoff-refused" | "merge:reuse-handoff-refused"
| "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:audit-failure" | "merge:audit-failure"
| "branch:auto-reclaim" | "branch:auto-reclaim"
| "branch:auto-canonicalize-case" | "branch:auto-canonicalize-case"