chore(FN-4428): import dependency content from fusion/fn-4410
Squash-imported the working tree of fusion/fn-4410 as a single commit so this branch carries the dep's content without inheriting its individual commits. If the dep is later squash-merged to main, this commit's patch-id should match the merge and rebase cleanly. Fusion-Task-Id: FN-4428 Fusion-Task-Lineage: ea174f50-49ac-4105-b6cf-db3bab0984fd
This commit is contained in:
@@ -101,7 +101,7 @@ describe("branch-conflicts", () => {
|
||||
expect(result).toEqual({ kind: "stale-resolved" });
|
||||
});
|
||||
|
||||
it("returns a typed live conflict with stranded commits", async () => {
|
||||
it("returns reclaimable for same-task live conflicts with stranded commits", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
@@ -117,6 +117,10 @@ describe("branch-conflicts", () => {
|
||||
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\nbbb222\tAdd regression coverage\n");
|
||||
}
|
||||
if (command.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\u0000feat(FN-4068): preserve\u0000Fusion-Task-Id: FN-4068\u0000" +
|
||||
"bbb222\u0000fix(FN-4068): regression\u0000Fusion-Task-Id: FN-4068\u0000");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
@@ -128,22 +132,57 @@ describe("branch-conflicts", () => {
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("live");
|
||||
if (result.kind !== "live") {
|
||||
throw new Error("expected live conflict");
|
||||
expect(result.kind).toBe("reclaimable");
|
||||
if (result.kind !== "reclaimable") {
|
||||
throw new Error("expected reclaimable conflict");
|
||||
}
|
||||
expect(result.error).toBeInstanceOf(BranchConflictError);
|
||||
expect(result.error).toMatchObject({
|
||||
branchName: "fusion/fn-4068",
|
||||
conflictingWorktreePath: "/tmp/existing-wt",
|
||||
existingTipSha: "abc123def456",
|
||||
startPoint: "main",
|
||||
expect(result).toMatchObject({
|
||||
livePath: "/tmp/existing-wt",
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 2,
|
||||
});
|
||||
expect(result.error.strandedCommits).toEqual([
|
||||
expect(result.strandedCommits).toEqual([
|
||||
{ sha: "aaa111", subject: "Preserve prior fix" },
|
||||
{ sha: "bbb222", subject: "Add regression coverage" },
|
||||
]);
|
||||
expect(result.error.message).toContain("2 stranded commits since main");
|
||||
});
|
||||
|
||||
it("returns live-foreign with BranchConflictError for cross-task collisions", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /tmp/existing-wt", "HEAD 2222222", "branch refs/heads/fusion/fn-4068", ""].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4068^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
}
|
||||
if (command.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\u0000feat(FN-9999): foreign\u0000Fusion-Task-Id: FN-9999\u0000");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "fusion/fn-4068",
|
||||
conflictingWorktreePath: "/tmp/existing-wt",
|
||||
requestingTaskId: "FN-4068",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("live-foreign");
|
||||
if (result.kind !== "live-foreign") {
|
||||
throw new Error("expected live-foreign conflict");
|
||||
}
|
||||
expect(result.error).toBeInstanceOf(BranchConflictError);
|
||||
expect(result.error.message).toContain("Run branch recovery");
|
||||
});
|
||||
|
||||
it("assertCleanBranchAtBase passes when no foreign task commits exist", async () => {
|
||||
|
||||
@@ -843,9 +843,11 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
});
|
||||
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({
|
||||
kind: "live",
|
||||
kind: "live-foreign",
|
||||
livePath: "/tmp/test/.worktrees/green-sage",
|
||||
error: conflictError,
|
||||
});
|
||||
vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(false);
|
||||
|
||||
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
||||
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
||||
@@ -1196,14 +1198,12 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/fn-049" });
|
||||
|
||||
// Should log that we're trying a new path
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Conflicting worktree in use by active task, trying new path"),
|
||||
expect.any(String),
|
||||
expect.stringContaining("Removed foreign conflicting worktree and retrying"),
|
||||
"/tmp/test/.worktrees/green-sage",
|
||||
);
|
||||
// Should generate a new name
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledTimes(2);
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalled();
|
||||
|
||||
const worktreeAddCalls = mockedExecSync.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
@@ -1215,13 +1215,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
command.endsWith('"fusion/fn-049"'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
worktreeAddCalls.some(
|
||||
(command) =>
|
||||
command.includes('git worktree add -b "fusion/fn-050-2"') &&
|
||||
command.endsWith('"fusion/fn-050"'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("removes stale branch and retries when branch exists without worktree", async () => {
|
||||
@@ -1805,7 +1799,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
||||
);
|
||||
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-064");
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-064", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -1828,7 +1822,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"fusion/fn-064",
|
||||
"fusion/fn-063",
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test" },
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-064" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1839,7 +1833,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
||||
);
|
||||
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-065");
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-065", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -1861,7 +1855,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"fusion/fn-065",
|
||||
undefined,
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test" },
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-065" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1873,7 +1867,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
);
|
||||
|
||||
// Pool returns a suffixed branch name due to conflict
|
||||
vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-066-2");
|
||||
vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-066-2", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -2170,8 +2164,6 @@ describe("TaskExecutor worktree pool integration", () => {
|
||||
"FN-020",
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
branch: "fusion/fn-020",
|
||||
worktree: "/tmp/test/.worktrees/existing-fn-020",
|
||||
paused: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("../worktree-pool.js", () => ({
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
||||
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { selfHealingLoggerMock } = vi.hoisted(() => ({
|
||||
@@ -74,13 +75,15 @@ import type { TaskStore, Settings, Task, AgentStore, Agent, NotificationProvider
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import { isUsableTaskWorktree, scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
import { createLogger } from "../logger.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
|
||||
const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
|
||||
const mockedCreateLogger = vi.mocked(createLogger);
|
||||
|
||||
type MockLogger = {
|
||||
@@ -5670,3 +5673,60 @@ describe("maintenance cycle concurrency", () => {
|
||||
await expect((manager as any).runMaintenance()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false } as any),
|
||||
});
|
||||
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
mockedIsUsableTaskWorktree.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("reclaims stranded same-task branch conflicts", async () => {
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-500", checkedOutBy: null, branch: "fusion/fn-500", worktree: "/tmp/fn-500", lineageId: "lin-1" }])
|
||||
.mockResolvedValueOnce([]);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: "/tmp/fn-500",
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 2,
|
||||
strandedCommits: [{ sha: "abc123", subject: "work" }],
|
||||
} as any);
|
||||
|
||||
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
expect(recovered).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-500", { worktree: "/tmp/fn-500", branch: "fusion/fn-500" });
|
||||
});
|
||||
|
||||
it("skips checked out tasks", async () => {
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-501", checkedOutBy: "agent-1", branch: "fusion/fn-501", worktree: "/tmp/fn-501" }])
|
||||
.mockResolvedValueOnce([]);
|
||||
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict");
|
||||
|
||||
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
expect(recovered).toBe(0);
|
||||
expect(inspectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escalates unrecoverable reclaim failures to in-review failed", async () => {
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-502", checkedOutBy: null, branch: "fusion/fn-502", worktree: "/tmp/fn-502" }])
|
||||
.mockResolvedValueOnce([]);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
expect(recovered).toBe(0);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-502", expect.objectContaining({
|
||||
status: "failed",
|
||||
paused: true,
|
||||
pausedReason: "branch-conflict-unrecoverable",
|
||||
}));
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-502", "in-review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("acquireTaskWorktree", () => {
|
||||
});
|
||||
|
||||
it("acquires from pool when enabled", async () => {
|
||||
const prepareForTask = vi.fn().mockResolvedValue("fusion/fn-1");
|
||||
const prepareForTask = vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath: "/tmp/pooled", reclaimed: false });
|
||||
const result = await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: process.cwd(),
|
||||
@@ -54,7 +54,12 @@ describe("acquireTaskWorktree", () => {
|
||||
createWorktree: vi.fn(),
|
||||
});
|
||||
expect(result.source).toBe("pool");
|
||||
expect(prepareForTask).toHaveBeenCalled();
|
||||
expect(prepareForTask).toHaveBeenCalledWith(
|
||||
"/tmp/pooled",
|
||||
"fusion/fn-1",
|
||||
undefined,
|
||||
expect.objectContaining({ requestingTaskId: "FN-1" }),
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: "/tmp/pooled", branch: "fusion/fn-1" });
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
scanOrphanedBranches,
|
||||
} from "../worktree-pool.js";
|
||||
import { BranchConflictError } from "../branch-conflicts.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
@@ -184,7 +185,7 @@ describe("WorktreePool", () => {
|
||||
describe("prepareForTask", () => {
|
||||
it("returns the original branch name on success", async () => {
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
||||
expect(result).toBe("fusion/fn-042");
|
||||
expect(result).toMatchObject({ branch: "fusion/fn-042", reclaimed: false, worktreePath: "/tmp/wt" });
|
||||
});
|
||||
|
||||
it("cleans dirty working tree before checkout", async () => {
|
||||
@@ -232,7 +233,7 @@ describe("WorktreePool", () => {
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-001");
|
||||
expect(result).toBe("fusion/fn-001");
|
||||
expect(result).toMatchObject({ branch: "fusion/fn-001", reclaimed: false, worktreePath: "/tmp/wt" });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): nothing to checkout"),
|
||||
@@ -255,13 +256,13 @@ describe("WorktreePool", () => {
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
||||
|
||||
expect(result).toBe("fusion/fn-042");
|
||||
expect(result).toMatchObject({ branch: "fusion/fn-042", reclaimed: false, worktreePath: "/tmp/wt" });
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): working tree already clean"),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a typed branch conflict when the canonical branch is already live elsewhere by default", async () => {
|
||||
it("returns reclaimed result when branch is already live elsewhere for the same task", async () => {
|
||||
mockedExistsSync.mockImplementation((p) => {
|
||||
if (p === "/other/wt") return true;
|
||||
return true;
|
||||
@@ -293,9 +294,57 @@ describe("WorktreePool", () => {
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(
|
||||
pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { repoDir: "/tmp/repo" })
|
||||
).rejects.toBeInstanceOf(BranchConflictError);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: "/other/wt",
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 1,
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Preserve prior fix" }],
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "main", {
|
||||
repoDir: "/tmp/repo",
|
||||
requestingTaskId: "FN-042",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
branch: "fusion/fn-042",
|
||||
worktreePath: "/other/wt",
|
||||
reclaimed: true,
|
||||
existingTipSha: "abc123def456",
|
||||
strandedCommitCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws BranchConflictError for cross-task live-foreign conflicts", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git checkout -B "fusion/fn-042" main') {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from("fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tForeign fix\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tfeat(FN-999): foreign\x1fFusion-Task-Id: FN-999\n");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, {
|
||||
repoDir: "/tmp/repo",
|
||||
requestingTaskId: "FN-042",
|
||||
})).rejects.toBeInstanceOf(BranchConflictError);
|
||||
|
||||
const checkoutCalls = mockedExecSync.mock.calls
|
||||
.map((c) => c[0])
|
||||
@@ -310,33 +359,20 @@ describe("WorktreePool", () => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git checkout -B "fusion/fn-042" fusion/fn-041') {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from(
|
||||
"fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'"
|
||||
);
|
||||
err.stderr = Buffer.from("fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'fusion/fn-041..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) return Buffer.from("abc123def456\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'fusion/fn-041..fusion/fn-042'")) return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'fusion/fn-041..fusion/fn-042'")) return Buffer.from("aaa111\u001ffeat(FN-999): foreign\u001fFusion-Task-Id: FN-999\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask(
|
||||
"/tmp/wt",
|
||||
"fusion/fn-042",
|
||||
"fusion/fn-041",
|
||||
{ allowSiblingBranchRename: true, repoDir: "/tmp/repo" },
|
||||
);
|
||||
expect(result).toBe("fusion/fn-042-2");
|
||||
|
||||
const checkoutCalls = mockedExecSync.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((c) => typeof c === "string" && c.includes("checkout -B"));
|
||||
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-2" fusion/fn-042');
|
||||
expect(checkoutCalls).not.toContain('git checkout -B "fusion/fn-042-2" fusion/fn-041');
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "fusion/fn-041", { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
|
||||
expect(result.branch).toBe("fusion/fn-042-2");
|
||||
});
|
||||
|
||||
it("increments suffix when lower suffixes are also in use in legacy rename mode", async () => {
|
||||
@@ -344,41 +380,22 @@ describe("WorktreePool", () => {
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') ||
|
||||
cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
|
||||
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') || cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'x' is already used by worktree at '/other/wt'`
|
||||
);
|
||||
err.stderr = Buffer.from("fatal: 'x' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) {
|
||||
return Buffer.from("bbb222ccc333\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) {
|
||||
return Buffer.from("bbb222\tFirst sibling\n");
|
||||
}
|
||||
if (cmdStr === "git worktree list --porcelain") return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) return Buffer.from("abc123def456\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-042'")) return Buffer.from("aaa111\u001ffeat(FN-999): foreign\u001fFusion-Task-Id: FN-999\n");
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) return Buffer.from("bbb222ccc333\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) return Buffer.from("bbb222\tFirst sibling\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask(
|
||||
"/tmp/wt",
|
||||
"fusion/fn-042",
|
||||
undefined,
|
||||
{ allowSiblingBranchRename: true, repoDir: "/tmp/repo" },
|
||||
);
|
||||
expect(result).toBe("fusion/fn-042-3");
|
||||
|
||||
const checkoutCalls = mockedExecSync.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((c) => typeof c === "string" && c.includes("checkout -B"));
|
||||
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-3" fusion/fn-042');
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
|
||||
expect(result.branch).toBe("fusion/fn-042-3");
|
||||
});
|
||||
|
||||
it("falls back to git worktree prune when conflicting worktree no longer exists on disk", async () => {
|
||||
@@ -406,7 +423,7 @@ describe("WorktreePool", () => {
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
||||
expect(result).toBe("fusion/fn-042");
|
||||
expect(result.branch).toBe("fusion/fn-042");
|
||||
|
||||
const cmds = mockedExecSync.mock.calls.map((c) => c[0]);
|
||||
expect(cmds).toContain("git worktree prune");
|
||||
@@ -429,52 +446,21 @@ describe("WorktreePool", () => {
|
||||
|
||||
it("throws when all suffixed names are exhausted in legacy rename mode", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("checkout -B")) {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'x' is already used by worktree at '/other/wt'`
|
||||
);
|
||||
err.stderr = Buffer.from("fatal: 'x' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) {
|
||||
return Buffer.from("bbb222ccc333\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) {
|
||||
return Buffer.from("bbb222\tFirst sibling\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-3^{commit}'")) {
|
||||
return Buffer.from("ccc333ddd444\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-3'")) {
|
||||
return Buffer.from("ccc333\tSecond sibling\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-4^{commit}'")) {
|
||||
return Buffer.from("ddd444eee555\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-4'")) {
|
||||
return Buffer.from("ddd444\tThird sibling\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-5^{commit}'")) {
|
||||
return Buffer.from("eee555fff666\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-5'")) {
|
||||
return Buffer.from("eee555\tFourth sibling\n");
|
||||
}
|
||||
if (cmdStr === "git worktree list --porcelain") return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) return Buffer.from("abc123def456\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-042'")) return Buffer.from("aaa111\u001ffeat(FN-999): foreign\u001fFusion-Task-Id: FN-999\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(
|
||||
pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" })
|
||||
).rejects.toThrow(/suffixes -2 through -6 are all in use/);
|
||||
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" })).rejects.toThrow(/suffixes -2 through -6 are all in use/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -100,8 +100,7 @@ export type BranchConflictInspectionResult =
|
||||
| { kind: "stale" }
|
||||
| { kind: "stale-resolved" }
|
||||
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
|
||||
| { kind: "live-foreign"; livePath: string }
|
||||
| { kind: "live"; error: BranchConflictError };
|
||||
| { kind: "live-foreign"; livePath: string; error: BranchConflictError };
|
||||
|
||||
export interface ListBranchRecoveryCandidatesInput {
|
||||
repoDir: string;
|
||||
@@ -287,38 +286,34 @@ export async function inspectBranchConflict(
|
||||
return { kind: "stale-resolved" };
|
||||
}
|
||||
|
||||
if (livePath !== input.conflictingWorktreePath) {
|
||||
const tipSha = await revParse(input.repoDir, input.branchName);
|
||||
const strandedCommits = await listStrandedCommits(input.repoDir, startPoint, input.branchName);
|
||||
const taskAttributedCommitCount = await countTaskAttributedCommits(
|
||||
input.repoDir,
|
||||
`${startPoint}..${input.branchName}`,
|
||||
input.requestingTaskId,
|
||||
);
|
||||
if (taskAttributedCommitCount > 0) {
|
||||
return {
|
||||
kind: "reclaimable",
|
||||
livePath,
|
||||
tipSha,
|
||||
taskAttributedCommitCount,
|
||||
strandedCommits,
|
||||
};
|
||||
}
|
||||
return { kind: "live-foreign", livePath };
|
||||
}
|
||||
|
||||
const existingTipSha = await revParse(input.repoDir, input.branchName);
|
||||
const strandedCommits = await listStrandedCommits(input.repoDir, startPoint, input.branchName);
|
||||
const taskAttributedCommitCount = await countTaskAttributedCommits(
|
||||
input.repoDir,
|
||||
`${startPoint}..${input.branchName}`,
|
||||
input.requestingTaskId,
|
||||
);
|
||||
|
||||
if (taskAttributedCommitCount > 0) {
|
||||
return {
|
||||
kind: "reclaimable",
|
||||
livePath,
|
||||
tipSha: existingTipSha,
|
||||
taskAttributedCommitCount,
|
||||
strandedCommits,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "live",
|
||||
kind: "live-foreign",
|
||||
livePath,
|
||||
error: new BranchConflictError({
|
||||
branchName: input.branchName,
|
||||
conflictingWorktreePath: input.conflictingWorktreePath,
|
||||
conflictingWorktreePath: livePath,
|
||||
existingTipSha,
|
||||
strandedCommits,
|
||||
startPoint,
|
||||
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
|
||||
recommendedAction: "Run branch recovery and explicitly choose whether to reclaim or discard prior work.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2503,6 +2503,21 @@ export class TaskExecutor {
|
||||
});
|
||||
worktreePath = acquisition.worktreePath;
|
||||
|
||||
if (acquisition.reclaimed) {
|
||||
await audit.git({
|
||||
type: "branch:auto-reclaim",
|
||||
target: acquisition.branch,
|
||||
metadata: {
|
||||
taskId: task.id,
|
||||
branch: acquisition.branch,
|
||||
worktreePath: acquisition.worktreePath,
|
||||
existingTipSha: acquisition.reclaimed.existingTipSha,
|
||||
strandedCommitCount: acquisition.reclaimed.strandedCommitCount ?? 0,
|
||||
trigger: "dispatch-preflight",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!acquisition.isResume && acquisition.source === "fresh" && settings.setupScript) {
|
||||
const scriptCommand = settings.scripts?.[settings.setupScript];
|
||||
if (scriptCommand) {
|
||||
@@ -7343,7 +7358,7 @@ and show an appropriate message to the user.\`
|
||||
}
|
||||
|
||||
if (!allowSiblingBranchRename) {
|
||||
throw inspection.error;
|
||||
throw new Error(`Branch ${branch} conflict could not be auto-resolved`);
|
||||
}
|
||||
|
||||
const conflictStartPoint = branch;
|
||||
|
||||
@@ -75,6 +75,7 @@ export type GitMutationType =
|
||||
| "merge:start"
|
||||
| "merge:resolve"
|
||||
| "merge:audit-failure"
|
||||
| "branch:auto-reclaim"
|
||||
| "stash:push"
|
||||
| "stash:pop";
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { getInReviewStallReason, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type TaskStore, type Settings, type Task, type MergeDetails } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getRegisteredWorktreePaths, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import { getRegisteredWorktreePaths, isUsableTaskWorktree, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import { extractMissingWorktreePathFromSessionStartFailure, isMissingWorktreeSessionStartFailure, isRecoverableMissingWorktreeReviewFailure } from "./restart-recovery-coordinator.js";
|
||||
import { classifyError, extractMissingModulePath, isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError } from "./transient-error-detector.js";
|
||||
import { inspectBranchConflict } from "./branch-conflicts.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
|
||||
const log = createLogger("self-healing");
|
||||
@@ -374,6 +375,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) },
|
||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks().then(() => undefined) },
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
|
||||
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
|
||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
|
||||
];
|
||||
|
||||
@@ -1073,6 +1075,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns() },
|
||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() },
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
|
||||
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
|
||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
|
||||
];
|
||||
for (const fn of batch2Fns) {
|
||||
@@ -1296,6 +1299,93 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDING: do not auto-discard stranded commits. Reclaim preserves commits;
|
||||
* unrecoverable conflicts are escalated for human review.
|
||||
*/
|
||||
async reclaimSelfOwnedBranchConflicts(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const candidates = [
|
||||
...(await this.store.listTasks({ column: "todo", slim: true })),
|
||||
...(await this.store.listTasks({ column: "in-progress", slim: true })),
|
||||
];
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
if (task.checkedOutBy || !task.branch || !task.worktree) continue;
|
||||
if (!await isUsableTaskWorktree(this.options.rootDir, task.worktree)) continue;
|
||||
|
||||
try {
|
||||
const inspection = await inspectBranchConflict({
|
||||
repoDir: this.options.rootDir,
|
||||
branchName: task.branch,
|
||||
conflictingWorktreePath: task.worktree,
|
||||
requestingTaskId: task.id,
|
||||
startPoint: task.baseCommitSha ?? task.mergeDetails?.mergeTargetBranch ?? "main",
|
||||
});
|
||||
|
||||
if (inspection.kind !== "reclaimable" || inspection.taskAttributedCommitCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, { worktree: inspection.livePath, branch: task.branch });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`[recovery] reclaimed existing worktree for ${task.id} at ${inspection.livePath} (${inspection.taskAttributedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
|
||||
);
|
||||
|
||||
try {
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal", task.id),
|
||||
agentId: "self-healing",
|
||||
taskId: task.id,
|
||||
taskLineageId: task.lineageId,
|
||||
phase: "reclaim-self-owned-branch-conflicts",
|
||||
});
|
||||
await auditor.git({
|
||||
type: "branch:auto-reclaim",
|
||||
target: task.branch,
|
||||
metadata: {
|
||||
taskId: task.id,
|
||||
branch: task.branch,
|
||||
worktreePath: inspection.livePath,
|
||||
existingTipSha: inspection.tipSha,
|
||||
strandedCommitCount: inspection.strandedCommits.length,
|
||||
trigger: "self-healing-sweep",
|
||||
},
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
log.warn(`Failed to write branch:auto-reclaim run-audit event for ${task.id}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
|
||||
}
|
||||
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.store.updateTask(task.id, {
|
||||
status: "failed",
|
||||
error: `Task branch conflict: ${task.branch} is not safely reclaimable (${message})`,
|
||||
paused: true,
|
||||
pausedReason: "branch-conflict-unrecoverable",
|
||||
});
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
await this.store.logEntry(task.id, `Auto-recovery failed: branch conflict unrecoverable — ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Reclaimed ${recovered} self-owned branch conflict task(s)`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Self-owned branch conflict reclaim sweep failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear `blockedBy` on todo tasks whose blocker has reached a terminal or
|
||||
* stuck state.
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface AcquireTaskWorktreeResult {
|
||||
source: "existing" | "pool" | "fresh";
|
||||
hydrated: boolean;
|
||||
isResume: boolean;
|
||||
reclaimed?: {
|
||||
existingTipSha?: string;
|
||||
strandedCommitCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function configuredCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string {
|
||||
@@ -142,21 +146,42 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
const pooled = pool.acquire();
|
||||
if (pooled) {
|
||||
try {
|
||||
const actualBranch = await pool.prepareForTask(pooled, branchName, baseBranch ?? undefined, { allowSiblingBranchRename, repoDir: rootDir });
|
||||
worktreePath = pooled;
|
||||
branch = actualBranch;
|
||||
const preparedRaw = await pool.prepareForTask(pooled, branchName, baseBranch ?? undefined, {
|
||||
allowSiblingBranchRename,
|
||||
repoDir: rootDir,
|
||||
requestingTaskId: task.id,
|
||||
});
|
||||
const prepared = typeof preparedRaw === "string"
|
||||
? { branch: preparedRaw, worktreePath: pooled, reclaimed: false as const }
|
||||
: preparedRaw;
|
||||
worktreePath = prepared.worktreePath;
|
||||
branch = prepared.branch;
|
||||
acquiredFromPool = true;
|
||||
logger?.log(`Acquired worktree from pool: ${pooled}`);
|
||||
await store.updateTask(task.id, { worktree: worktreePath, branch: actualBranch });
|
||||
await audit?.git({ type: "worktree:reuse", target: worktreePath, metadata: { branch: actualBranch } });
|
||||
if (actualBranch !== branchName) {
|
||||
logger?.log(`Branch conflict resolved: using ${actualBranch} instead of ${branchName}`);
|
||||
await store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath} (branch conflict: using ${actualBranch})`, undefined, runContext);
|
||||
logger?.log(`Acquired worktree from pool: ${worktreePath}`);
|
||||
await store.updateTask(task.id, { worktree: worktreePath, branch });
|
||||
await audit?.git({ type: "worktree:reuse", target: worktreePath, metadata: { branch, reclaimed: prepared.reclaimed } });
|
||||
if (prepared.reclaimed) {
|
||||
await store.logEntry(task.id, `Acquired reclaimed worktree from pool: ${worktreePath} (${prepared.strandedCommitCount ?? 0} commits preserved)`, undefined, runContext);
|
||||
} else if (branch !== branchName) {
|
||||
logger?.log(`Branch conflict resolved: using ${branch} instead of ${branchName}`);
|
||||
await store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath} (branch conflict: using ${branch})`, undefined, runContext);
|
||||
} else {
|
||||
await store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath}`, undefined, runContext);
|
||||
}
|
||||
const hydrated = await hydrate(worktreePath);
|
||||
return { worktreePath, branch, source: "pool", hydrated, isResume: false };
|
||||
return {
|
||||
worktreePath,
|
||||
branch,
|
||||
source: "pool",
|
||||
hydrated,
|
||||
isResume: false,
|
||||
reclaimed: prepared.reclaimed
|
||||
? {
|
||||
existingTipSha: prepared.existingTipSha,
|
||||
strandedCommitCount: prepared.strandedCommitCount,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch (poolErr) {
|
||||
pool.release(pooled);
|
||||
if (isBranchConflictError(poolErr)) throw poolErr;
|
||||
|
||||
@@ -106,6 +106,14 @@ function deriveTaskIdFromBranch(branchName: string): string {
|
||||
return match ? match[1].toUpperCase() : branchName.toUpperCase();
|
||||
}
|
||||
|
||||
export type PrepareForTaskResult = {
|
||||
branch: string;
|
||||
worktreePath: string;
|
||||
reclaimed: boolean;
|
||||
existingTipSha?: string;
|
||||
strandedCommitCount?: number;
|
||||
};
|
||||
|
||||
export class WorktreePool {
|
||||
private idle = new Set<string>();
|
||||
|
||||
@@ -207,8 +215,8 @@ export class WorktreePool {
|
||||
worktreePath: string,
|
||||
branchName: string,
|
||||
startPoint?: string,
|
||||
options?: { allowSiblingBranchRename?: boolean; repoDir?: string },
|
||||
): Promise<string> {
|
||||
options?: { allowSiblingBranchRename?: boolean; repoDir?: string; requestingTaskId?: string },
|
||||
): Promise<PrepareForTaskResult> {
|
||||
// Clean tracked modifications
|
||||
try {
|
||||
await execAsync("git checkout -- .", { cwd: worktreePath });
|
||||
@@ -235,11 +243,11 @@ export class WorktreePool {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
await assertCleanBranchAtBase(worktreePath, branchName, resolvedBase, taskId);
|
||||
return branchName;
|
||||
return { branch: branchName, worktreePath, reclaimed: false };
|
||||
} catch (err: unknown) {
|
||||
const execError = err instanceof Error ? err : new Error(String(err));
|
||||
const stderr = "stderr" in execError && typeof execError.stderr === "string"
|
||||
? execError.stderr.toString()
|
||||
const stderr = "stderr" in execError
|
||||
? String((execError as { stderr?: unknown }).stderr ?? execError.message)
|
||||
: execError.message;
|
||||
const match = stderr.match(/already used by worktree at '([^']+)'/);
|
||||
if (!match) {
|
||||
@@ -255,18 +263,31 @@ export class WorktreePool {
|
||||
repoDir: options?.repoDir ?? worktreePath,
|
||||
branchName,
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: branchName,
|
||||
requestingTaskId: options?.requestingTaskId ?? taskId,
|
||||
startPoint: base,
|
||||
});
|
||||
if (inspection.kind === "stale") {
|
||||
if (inspection.kind === "stale" || inspection.kind === "stale-resolved") {
|
||||
await execAsync("git worktree prune", { cwd: worktreePath });
|
||||
await execAsync(checkoutCmd, { cwd: worktreePath });
|
||||
await assertCleanBranchAtBase(worktreePath, branchName, resolvedBase, taskId);
|
||||
return branchName;
|
||||
return { branch: branchName, worktreePath, reclaimed: false };
|
||||
}
|
||||
|
||||
if (inspection.kind === "reclaimable") {
|
||||
worktreePoolLog.log(
|
||||
`reclaimed self-owned branch conflict for ${branchName}: tip=${inspection.tipSha} strandedSince${base}=${inspection.strandedCommits.length}`,
|
||||
);
|
||||
return {
|
||||
branch: branchName,
|
||||
worktreePath: inspection.livePath,
|
||||
reclaimed: true,
|
||||
existingTipSha: inspection.tipSha,
|
||||
strandedCommitCount: inspection.strandedCommits.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (!options?.allowSiblingBranchRename) {
|
||||
if (inspection.kind === "live") {
|
||||
if (inspection.kind === "live-foreign") {
|
||||
throw inspection.error;
|
||||
}
|
||||
throw new Error(`Branch ${branchName} is already in use at ${conflictingPath}`);
|
||||
@@ -279,7 +300,7 @@ export class WorktreePool {
|
||||
try {
|
||||
await execAsync(suffixedCmd, { cwd: worktreePath });
|
||||
await assertCleanBranchAtBase(worktreePath, suffixedName, resolvedBase, taskId);
|
||||
return suffixedName;
|
||||
return { branch: suffixedName, worktreePath, reclaimed: false };
|
||||
} catch (suffixErr: unknown) {
|
||||
const suffixExecError = suffixErr instanceof Error ? suffixErr : new Error(String(suffixErr));
|
||||
const suffixStderr = "stderr" in suffixExecError && typeof suffixExecError.stderr === "string"
|
||||
|
||||
Reference in New Issue
Block a user