Merge FN-5353: reacquire fresh worktree on reuse handoff refusal
This commit is contained in:
7
.changeset/fix-fn-5353-reacquire-worktree.md
Normal file
7
.changeset/fix-fn-5353-reacquire-worktree.md
Normal 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`.
|
||||
@@ -942,6 +942,7 @@ export type ActivityEventType =
|
||||
| "task:auto-archived-near-duplicate"
|
||||
| "task:auto-archived-ghost-bug"
|
||||
| "task:auto-archived-duplicate"
|
||||
| "task:merge-worktree-reacquired"
|
||||
| "settings:updated"
|
||||
| "project:isolation-transition";
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
|
||||
"task:duplicate-warning-overridden": "Duplicate Warning Overridden",
|
||||
"task:auto-archived-ghost-bug": "Task Auto-Archived (Ghost Bug)",
|
||||
"task:auto-archived-duplicate": "Task Auto-Archived (Duplicate)",
|
||||
"task:merge-worktree-reacquired": "Merge Worktree Reacquired",
|
||||
"task:auto-archived-deterministic-duplicate": "Task Auto-Archived (Deterministic Duplicate)",
|
||||
"task:auto-archived-near-duplicate": "Task Auto-Archived (Near-Duplicate)",
|
||||
"settings:updated": "Settings Updated",
|
||||
@@ -51,6 +52,7 @@ const EVENT_TYPE_ICONS: Record<ActivityEventType, React.ReactNode> = {
|
||||
"task:auto-archived-duplicate": <Trash2 size={14} className="activity-icon deleted" />,
|
||||
"task:auto-archived-deterministic-duplicate": <Trash2 size={14} className="activity-icon deleted" />,
|
||||
"task:auto-archived-near-duplicate": <Trash2 size={14} className="activity-icon deleted" />,
|
||||
"task:merge-worktree-reacquired": <RefreshCw size={14} className="activity-icon updated" />,
|
||||
"settings:updated": <Settings size={14} className="activity-icon settings" />,
|
||||
"project:isolation-transition": <Folder size={14} className="activity-icon settings" />,
|
||||
};
|
||||
|
||||
@@ -349,6 +349,52 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("reacquires a fresh task worktree 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-new-worktree");
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||
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({
|
||||
reason: "missing-task-worktree",
|
||||
source: "fresh",
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("cwd-main mode stays on the legacy path and emits no reuse handoff events", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5279-RI-CWD-MAIN",
|
||||
|
||||
@@ -74,7 +74,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.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 { AgentLogger } from "./agent-logger.js";
|
||||
import { mergerLog } from "./logger.js";
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
resolveMergeIntegrationRoot,
|
||||
type HandoffResult,
|
||||
} from "./merger-integration-worktree.js";
|
||||
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
||||
|
||||
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
|
||||
@@ -6369,7 +6370,7 @@ export async function aiMergeTask(
|
||||
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||
projectDefaultBranch,
|
||||
});
|
||||
const branch = task.branch || canonicalFusionBranchName(taskId);
|
||||
let branch = task.branch || canonicalFusionBranchName(taskId);
|
||||
|
||||
const mergeRunId = generateSyntheticRunId("merge", taskId);
|
||||
const engineRunContext: EngineRunContext = {
|
||||
@@ -6386,6 +6387,7 @@ export async function aiMergeTask(
|
||||
| "merge:reuse-handoff-refused"
|
||||
| "merge:reuse-handoff-released"
|
||||
| "merge:reuse-handoff-deferred-to-worktrunk"
|
||||
| "merge:reuse-fallback-new-worktree"
|
||||
| "branch:auto-canonicalize-case",
|
||||
metadata: Record<string, unknown>,
|
||||
target: string,
|
||||
@@ -6402,18 +6404,80 @@ export async function aiMergeTask(
|
||||
const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main"
|
||||
? "cwd-main"
|
||||
: "reuse-task-worktree";
|
||||
const integrationRoot = resolveMergeIntegrationRoot({
|
||||
let integrationRoot = resolveMergeIntegrationRoot({
|
||||
task,
|
||||
settings,
|
||||
projectRoot: projectRootDir,
|
||||
});
|
||||
const reuseTaskWorktreeMerge = integrationRoot.mode === "reuse-task-worktree";
|
||||
let reuseTaskWorktreeMerge = integrationRoot.mode === "reuse-task-worktree";
|
||||
rootDir = integrationRoot.rootDir;
|
||||
const integrationRemote = await resolveIntegrationRemote({
|
||||
let integrationRemote = await resolveIntegrationRemote({
|
||||
settings,
|
||||
rootDir: rootDir,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
});
|
||||
const reacquireReuseIntegrationWorktree = async (
|
||||
reason: string,
|
||||
diagnostics: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
const acquisition = await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: projectRootDir,
|
||||
store,
|
||||
settings,
|
||||
pool: options.pool,
|
||||
logger: mergerLog,
|
||||
audit,
|
||||
runContext: engineRunContext,
|
||||
runInitCommand: false,
|
||||
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, branch };
|
||||
},
|
||||
});
|
||||
task.worktree = acquisition.worktreePath;
|
||||
task.branch = acquisition.branch;
|
||||
branch = acquisition.branch;
|
||||
integrationRoot = {
|
||||
...integrationRoot,
|
||||
mode: "reuse-task-worktree",
|
||||
rootDir: acquisition.worktreePath,
|
||||
branchName: acquisition.branch,
|
||||
};
|
||||
reuseTaskWorktreeMerge = true;
|
||||
rootDir = acquisition.worktreePath;
|
||||
integrationRemote = await resolveIntegrationRemote({
|
||||
settings,
|
||||
rootDir,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
});
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-fallback-new-worktree",
|
||||
{
|
||||
taskId,
|
||||
reason,
|
||||
branch: acquisition.branch,
|
||||
worktreePath: acquisition.worktreePath,
|
||||
source: acquisition.source,
|
||||
diagnostics,
|
||||
integrationRemote: integrationRemote ?? null,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
},
|
||||
acquisition.worktreePath,
|
||||
);
|
||||
await store.recordActivity({
|
||||
type: "task:merge-worktree-reacquired",
|
||||
taskId,
|
||||
taskTitle: task.title,
|
||||
details: `Merge worktree reacquired: ${reason}`,
|
||||
metadata: { reason, branch: acquisition.branch, worktreePath: acquisition.worktreePath, source: acquisition.source },
|
||||
});
|
||||
};
|
||||
if (
|
||||
settings.worktrunk?.enabled === true
|
||||
&& requestedIntegrationMode === "reuse-task-worktree"
|
||||
@@ -6432,6 +6496,22 @@ export async function aiMergeTask(
|
||||
}
|
||||
|
||||
let reuseHandoff: HandoffResult | undefined;
|
||||
if (integrationRoot.mode === "reuse-task-worktree") {
|
||||
const reusableWorktreePath = task.worktree?.trim();
|
||||
if (!reusableWorktreePath) {
|
||||
await reacquireReuseIntegrationWorktree("missing-task-worktree", {
|
||||
requestedMode: requestedIntegrationMode,
|
||||
});
|
||||
} else {
|
||||
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
|
||||
if (!classification.ok) {
|
||||
await reacquireReuseIntegrationWorktree("unusable-task-worktree", {
|
||||
requestedMode: requestedIntegrationMode,
|
||||
classification,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (integrationRoot.mode === "reuse-task-worktree") {
|
||||
try {
|
||||
reuseHandoff = await acquireReuseHandoff({
|
||||
@@ -6453,20 +6533,40 @@ export async function aiMergeTask(
|
||||
},
|
||||
reuseHandoff.worktreePath,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof MergeHandoffRefusedError) {
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-handoff-refused",
|
||||
{
|
||||
taskId,
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof MergeHandoffRefusedError)) {
|
||||
throw error;
|
||||
}
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-handoff-refused",
|
||||
{
|
||||
taskId,
|
||||
gate: error.gate,
|
||||
reason: error.reason,
|
||||
diagnostics: error.payload,
|
||||
},
|
||||
integrationRoot.rootDir,
|
||||
);
|
||||
const reusableWorktreePath = task.worktree?.trim();
|
||||
if (!reusableWorktreePath) {
|
||||
await reacquireReuseIntegrationWorktree("missing-task-worktree-after-refusal", {
|
||||
requestedMode: requestedIntegrationMode,
|
||||
gate: error.gate,
|
||||
reason: error.reason,
|
||||
});
|
||||
} else {
|
||||
const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath);
|
||||
if (!classification.ok) {
|
||||
await reacquireReuseIntegrationWorktree("unusable-task-worktree-after-refusal", {
|
||||
requestedMode: requestedIntegrationMode,
|
||||
gate: error.gate,
|
||||
reason: error.reason,
|
||||
diagnostics: error.payload,
|
||||
},
|
||||
integrationRoot.rootDir,
|
||||
);
|
||||
classification,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,9 @@ export type GitMutationType =
|
||||
| "merge:reuse-handoff-refused"
|
||||
| "merge:reuse-handoff-released"
|
||||
| "merge:reuse-handoff-deferred-to-worktrunk"
|
||||
| "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"
|
||||
|
||||
Reference in New Issue
Block a user