feat(FN-2944): merge fusion/fn-2944

- test(FN-2944): cover already checked out worktree conflict recovery
- fix(FN-2944): recognize git already checked out worktree conflict
- fix(engine): auto-recover from squash-merge orphan rebase failures

Fusion-Task-Id: FN-2944
This commit is contained in:
Fusion
2026-04-29 07:10:52 -07:00
committed by gsxdsm
parent 98fb71c202
commit 995165ea60
46 changed files with 454 additions and 163 deletions

View File

@@ -966,6 +966,56 @@ describe("TaskExecutor worktree recovery", () => {
expect(worktreeAddCalls).toHaveLength(1);
});
it("extractWorktreeConflictInfo classifies already checked out errors as already-used", () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
const error: any = new Error(
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
);
error.stderr = Buffer.from(
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
);
const conflictInfo = (executor as any).extractWorktreeConflictInfo(error);
expect(conflictInfo).toMatchObject({
type: "already-used",
path: "/tmp/test/.worktrees/green-sage",
});
});
it("recovers from already checked out worktree conflict and retries", async () => {
const store = createMockStore();
let callCount = 0;
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git worktree add") && callCount++ === 0) {
const error: any = new Error(
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
);
error.stderr = Buffer.from(
"fatal: 'fusion/fn-050' is already checked out at '/tmp/test/.worktrees/green-sage'",
);
throw error;
}
return Buffer.from("");
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
"/tmp/test/.worktrees/swift-falcon",
);
expect(store.updateTask).toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({ worktree: expect.any(String) }),
);
});
it("recovers from worktree conflict and retries", async () => {
const store = createMockStore();
let callCount = 0;

View File

@@ -10,3 +10,11 @@ import { join } from "node:path";
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
if (process.platform === "win32") {
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}

View File

@@ -5107,6 +5107,12 @@ and show an appropriate message to the user.\`
return { type: "already-used", path: alreadyUsedMatch[1], message: output };
}
// Pattern: already checked out at '/path/to/worktree'
const alreadyCheckedOutMatch = output.match(/is already checked out at '([^']+)'/);
if (alreadyCheckedOutMatch) {
return { type: "already-used", path: alreadyCheckedOutMatch[1], message: output };
}
// Pattern: invalid reference: 'branch-name'
// Also covers: unable to resolve reference, stale file handle, not a valid ref
if (

View File

@@ -792,8 +792,11 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
* `/project` → null (not a worktree)
*/
function getProjectRootFromWorktree(cwd: string): string | null {
// Match paths like /project/.worktrees/task-id or /project/.worktrees/task-id/...
const match = cwd.match(/^(.+?)\/\.worktrees\/[^/]+/);
// Match paths like:
// /project/.worktrees/task-id
// /project/.worktrees/task-id/src/file.ts
// C:\project\.worktrees\task-id
const match = cwd.match(/^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/]|$)/);
if (match) {
return match[1]!;
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import type { Task, TaskStore, CentralCore, AgentStore, Agent } from "@fusion/core";
@@ -184,7 +185,7 @@ describe("InProcessRuntime", () => {
beforeEach(() => {
// Create a unique temp directory for this test run
testDir = mkdtempSync(join("/tmp", `fn-test-${randomUUID().slice(0, 8)}-`));
testDir = mkdtempSync(join(tmpdir(), `fn-test-${randomUUID().slice(0, 8)}-`));
// Create mock CentralCore
mockCentralCore = {
@@ -746,12 +747,12 @@ describe("InProcessRuntime", () => {
it("should store projectId in config", () => {
// Access via the constructor params - runtime is created with testDir
expect(testDir).toBeDefined();
expect(testDir).toContain("/tmp/fn-test-");
expect(testDir).toContain("fn-test-");
});
it("should store workingDirectory in config", () => {
expect(testDir).toBeDefined();
expect(testDir.startsWith("/tmp/")).toBe(true);
expect(testDir.startsWith(tmpdir())).toBe(true);
});
it("should store maxConcurrent in config", () => {

View File

@@ -10,7 +10,7 @@ import {
} from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { basename, join } from "node:path";
import type { AgentSemaphore } from "./concurrency.js";
import { generateReservedWorktreeName, slugify } from "./worktree-names.js";
import { schedulerLog } from "./logger.js";
@@ -466,7 +466,7 @@ export class Scheduler {
reservedNames: Set<string>,
): string {
if (task.worktree) {
const existingName = task.worktree.split("/").pop();
const existingName = basename(task.worktree);
if (existingName) reservedNames.add(existingName);
return task.worktree;
}
@@ -659,7 +659,7 @@ export class Scheduler {
let started = 0;
const reservedWorktreeNames = new Set(
tasks
.map((task) => task.worktree?.split("/").pop())
.map((task) => (task.worktree ? basename(task.worktree) : undefined))
.filter((name): name is string => Boolean(name)),
);