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:
@@ -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;
|
||||
|
||||
@@ -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] || "\\";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user