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:
5
.changeset/fn-4410-auto-reclaim-self-branch.md
Normal file
5
.changeset/fn-4410-auto-reclaim-self-branch.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix self-owned task branch conflicts so dispatch can reclaim an existing task worktree/branch instead of hard-failing with a branch conflict. Add a self-healing sweep that reclaims stranded self-owned branch conflicts for idle todo/in-progress tasks and emits `branch:auto-reclaim` run-audit telemetry including task/branch/worktree/tip/stranded commit metadata. Cross-task (`live-foreign`) branch collisions remain blocked and still require `fn task branch-recovery`.
|
||||||
@@ -196,7 +196,7 @@ Port 4040 is the production dashboard port. A user's live dashboard session is t
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- Merge deadlock self-healing now has three layered defenses: `SelfHealingManager.recoverAlreadyMergedReviewTasks()` and `SelfHealingManager.clearStaleBlockedBy()` in `packages/engine/src/self-healing.ts`, plus the paused-aware in-review scope filter in `packages/engine/src/scheduler.ts` (`inReviewWithWorktree` excludes `paused` tasks). Together these auto-finalize already-landed retry-exhausted review tasks, clear stale downstream blockers, and prevent paused review cards from re-blocking overlap dispatch.
|
- Merge deadlock self-healing now has three layered defenses: `SelfHealingManager.recoverAlreadyMergedReviewTasks()`, `SelfHealingManager.clearStaleBlockedBy()`, and `SelfHealingManager.reclaimSelfOwnedBranchConflicts()` in `packages/engine/src/self-healing.ts`, plus the paused-aware in-review scope filter in `packages/engine/src/scheduler.ts` (`inReviewWithWorktree` excludes `paused` tasks). Together these auto-finalize already-landed retry-exhausted review tasks, clear stale downstream blockers, auto-reclaim self-owned stranded branch/worktree conflicts, and prevent paused review cards from re-blocking overlap dispatch.
|
||||||
- Restart recovery is coordinated through `RestartRecoveryCoordinator` (`packages/engine/src/restart-recovery-coordinator.ts`), which classifies interrupted `in-progress` runs at runtime startup: no-progress `fn_task_done` failures are safely requeued to `todo`, then remaining orphaned work is resumed via the executor.
|
- Restart recovery is coordinated through `RestartRecoveryCoordinator` (`packages/engine/src/restart-recovery-coordinator.ts`), which classifies interrupted `in-progress` runs at runtime startup: no-progress `fn_task_done` failures are safely requeued to `todo`, then remaining orphaned work is resumed via the executor.
|
||||||
|
|
||||||
## Engine Process Rules
|
## Engine Process Rules
|
||||||
|
|||||||
@@ -1398,6 +1398,12 @@ When a tracked task transitions into `done`, Fusion closes the linked GitHub iss
|
|||||||
- Executor creates branches like `fusion/{task-id}` (`executor.ts`)
|
- Executor creates branches like `fusion/{task-id}` (`executor.ts`)
|
||||||
- `WorktreePool` can recycle idle worktrees when enabled
|
- `WorktreePool` can recycle idle worktrees when enabled
|
||||||
|
|
||||||
|
#### Branch-conflict inspection and auto-reclaim
|
||||||
|
- `inspectBranchConflict` classifies branch collisions as `stale`, `stale-resolved`, `reclaimable`, or `live-foreign`.
|
||||||
|
- Dispatch preflight (`acquireTaskWorktree`/executor) now auto-reclaims `reclaimable` self-owned conflicts and emits `branch:auto-reclaim` run-audit events with task/branch/worktree/tip/stranded-commit metadata.
|
||||||
|
- Self-healing also runs `reclaimSelfOwnedBranchConflicts()` across idle `todo` + `in-progress` tasks; successful reclaim keeps stranded commits intact and failed reclaim escalates to `in-review`/`failed` with `branch-conflict-unrecoverable`.
|
||||||
|
- Cross-task collisions (`live-foreign`) remain manual by design and still surface `fn task branch-recovery <taskId>` as the escape hatch.
|
||||||
|
|
||||||
### Merge strategies
|
### Merge strategies
|
||||||
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
|
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
|
||||||
- `aiMergeTask()` in `merger.ts` performs merge flow
|
- `aiMergeTask()` in `merger.ts` performs merge flow
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe("branch-conflicts", () => {
|
|||||||
expect(result).toEqual({ kind: "stale-resolved" });
|
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[]) => {
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||||
if (command === "git worktree prune") return Buffer.from("");
|
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'")) {
|
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");
|
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}`);
|
throw new Error(`Unexpected command: ${command}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,22 +132,57 @@ describe("branch-conflicts", () => {
|
|||||||
startPoint: "main",
|
startPoint: "main",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.kind).toBe("live");
|
expect(result.kind).toBe("reclaimable");
|
||||||
if (result.kind !== "live") {
|
if (result.kind !== "reclaimable") {
|
||||||
throw new Error("expected live conflict");
|
throw new Error("expected reclaimable conflict");
|
||||||
}
|
}
|
||||||
expect(result.error).toBeInstanceOf(BranchConflictError);
|
expect(result).toMatchObject({
|
||||||
expect(result.error).toMatchObject({
|
livePath: "/tmp/existing-wt",
|
||||||
branchName: "fusion/fn-4068",
|
tipSha: "abc123def456",
|
||||||
conflictingWorktreePath: "/tmp/existing-wt",
|
taskAttributedCommitCount: 2,
|
||||||
existingTipSha: "abc123def456",
|
|
||||||
startPoint: "main",
|
|
||||||
});
|
});
|
||||||
expect(result.error.strandedCommits).toEqual([
|
expect(result.strandedCommits).toEqual([
|
||||||
{ sha: "aaa111", subject: "Preserve prior fix" },
|
{ sha: "aaa111", subject: "Preserve prior fix" },
|
||||||
{ sha: "bbb222", subject: "Add regression coverage" },
|
{ 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 () => {
|
it("assertCleanBranchAtBase passes when no foreign task commits exist", async () => {
|
||||||
|
|||||||
@@ -843,9 +843,11 @@ describe("TaskExecutor worktree recovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({
|
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({
|
||||||
kind: "live",
|
kind: "live-foreign",
|
||||||
|
livePath: "/tmp/test/.worktrees/green-sage",
|
||||||
error: conflictError,
|
error: conflictError,
|
||||||
});
|
});
|
||||||
|
vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(false);
|
||||||
|
|
||||||
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
||||||
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");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/fn-049" });
|
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/fn-049" });
|
||||||
|
|
||||||
// Should log that we're trying a new path
|
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"FN-050",
|
"FN-050",
|
||||||
expect.stringContaining("Conflicting worktree in use by active task, trying new path"),
|
expect.stringContaining("Removed foreign conflicting worktree and retrying"),
|
||||||
expect.any(String),
|
"/tmp/test/.worktrees/green-sage",
|
||||||
);
|
);
|
||||||
// Should generate a new name
|
expect(mockedGenerateWorktreeName).toHaveBeenCalled();
|
||||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
const worktreeAddCalls = mockedExecSync.mock.calls
|
const worktreeAddCalls = mockedExecSync.mock.calls
|
||||||
.map((call) => String(call[0]))
|
.map((call) => String(call[0]))
|
||||||
@@ -1215,13 +1215,7 @@ describe("TaskExecutor worktree recovery", () => {
|
|||||||
command.endsWith('"fusion/fn-049"'),
|
command.endsWith('"fusion/fn-049"'),
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||||
worktreeAddCalls.some(
|
|
||||||
(command) =>
|
|
||||||
command.includes('git worktree add -b "fusion/fn-050-2"') &&
|
|
||||||
command.endsWith('"fusion/fn-050"'),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes stale branch and retries when branch exists without worktree", async () => {
|
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",
|
(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();
|
const store = createMockStore();
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -1828,7 +1822,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
|||||||
"/tmp/test/.worktrees/idle-wt",
|
"/tmp/test/.worktrees/idle-wt",
|
||||||
"fusion/fn-064",
|
"fusion/fn-064",
|
||||||
"fusion/fn-063",
|
"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",
|
(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();
|
const store = createMockStore();
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -1861,7 +1855,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
|||||||
"/tmp/test/.worktrees/idle-wt",
|
"/tmp/test/.worktrees/idle-wt",
|
||||||
"fusion/fn-065",
|
"fusion/fn-065",
|
||||||
undefined,
|
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
|
// 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();
|
const store = createMockStore();
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -2170,8 +2164,6 @@ describe("TaskExecutor worktree pool integration", () => {
|
|||||||
"FN-020",
|
"FN-020",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
branch: "fusion/fn-020",
|
|
||||||
worktree: "/tmp/test/.worktrees/existing-fn-020",
|
|
||||||
paused: true,
|
paused: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ vi.mock("../worktree-pool.js", () => ({
|
|||||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||||
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
||||||
|
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { selfHealingLoggerMock } = vi.hoisted(() => ({
|
const { selfHealingLoggerMock } = vi.hoisted(() => ({
|
||||||
@@ -74,13 +75,15 @@ import type { TaskStore, Settings, Task, AgentStore, Agent, NotificationProvider
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
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 { createLogger } from "../logger.js";
|
||||||
import { NotificationService } from "../notification/notification-service.js";
|
import { NotificationService } from "../notification/notification-service.js";
|
||||||
|
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
const mockedExistsSync = vi.mocked(existsSync);
|
const mockedExistsSync = vi.mocked(existsSync);
|
||||||
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
|
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
|
||||||
|
const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
|
||||||
const mockedCreateLogger = vi.mocked(createLogger);
|
const mockedCreateLogger = vi.mocked(createLogger);
|
||||||
|
|
||||||
type MockLogger = {
|
type MockLogger = {
|
||||||
@@ -5670,3 +5673,60 @@ describe("maintenance cycle concurrency", () => {
|
|||||||
await expect((manager as any).runMaintenance()).resolves.toBeUndefined();
|
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 () => {
|
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({
|
const result = await acquireTaskWorktree({
|
||||||
task,
|
task,
|
||||||
rootDir: process.cwd(),
|
rootDir: process.cwd(),
|
||||||
@@ -54,7 +54,12 @@ describe("acquireTaskWorktree", () => {
|
|||||||
createWorktree: vi.fn(),
|
createWorktree: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(result.source).toBe("pool");
|
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" });
|
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: "/tmp/pooled", branch: "fusion/fn-1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
scanOrphanedBranches,
|
scanOrphanedBranches,
|
||||||
} from "../worktree-pool.js";
|
} from "../worktree-pool.js";
|
||||||
import { BranchConflictError } from "../branch-conflicts.js";
|
import { BranchConflictError } from "../branch-conflicts.js";
|
||||||
|
import * as branchConflictModule from "../branch-conflicts.js";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
||||||
import type { Task, Column } from "@fusion/core";
|
import type { Task, Column } from "@fusion/core";
|
||||||
@@ -184,7 +185,7 @@ describe("WorktreePool", () => {
|
|||||||
describe("prepareForTask", () => {
|
describe("prepareForTask", () => {
|
||||||
it("returns the original branch name on success", async () => {
|
it("returns the original branch name on success", async () => {
|
||||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
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 () => {
|
it("cleans dirty working tree before checkout", async () => {
|
||||||
@@ -232,7 +233,7 @@ describe("WorktreePool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-001");
|
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(errorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): nothing to checkout"),
|
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");
|
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(errorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): working tree already clean"),
|
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) => {
|
mockedExistsSync.mockImplementation((p) => {
|
||||||
if (p === "/other/wt") return true;
|
if (p === "/other/wt") return true;
|
||||||
return true;
|
return true;
|
||||||
@@ -293,9 +294,57 @@ describe("WorktreePool", () => {
|
|||||||
return Buffer.from("");
|
return Buffer.from("");
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||||
pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { repoDir: "/tmp/repo" })
|
kind: "reclaimable",
|
||||||
).rejects.toBeInstanceOf(BranchConflictError);
|
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
|
const checkoutCalls = mockedExecSync.mock.calls
|
||||||
.map((c) => c[0])
|
.map((c) => c[0])
|
||||||
@@ -310,33 +359,20 @@ describe("WorktreePool", () => {
|
|||||||
const cmdStr = String(cmd);
|
const cmdStr = String(cmd);
|
||||||
if (cmdStr === 'git checkout -B "fusion/fn-042" fusion/fn-041') {
|
if (cmdStr === 'git checkout -B "fusion/fn-042" fusion/fn-041') {
|
||||||
const err: any = new Error("branch conflict");
|
const err: any = new Error("branch conflict");
|
||||||
err.stderr = Buffer.from(
|
err.stderr = Buffer.from("fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'");
|
||||||
"fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'"
|
|
||||||
);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
if (cmdStr === "git worktree list --porcelain") {
|
||||||
return Buffer.from("abc123def456\n");
|
return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\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 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("");
|
return Buffer.from("");
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await pool.prepareForTask(
|
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "fusion/fn-041", { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
|
||||||
"/tmp/wt",
|
expect(result.branch).toBe("fusion/fn-042-2");
|
||||||
"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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("increments suffix when lower suffixes are also in use in legacy rename mode", async () => {
|
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) => {
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
const cmdStr = String(cmd);
|
const cmdStr = String(cmd);
|
||||||
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') ||
|
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') || cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
|
||||||
cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
|
|
||||||
const err: any = new Error("branch conflict");
|
const err: any = new Error("branch conflict");
|
||||||
err.stderr = Buffer.from(
|
err.stderr = Buffer.from("fatal: 'x' is already used by worktree at '/other/wt'");
|
||||||
`fatal: 'x' is already used by worktree at '/other/wt'`
|
|
||||||
);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
if (cmdStr === "git worktree list --porcelain") return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||||
return Buffer.from("abc123def456\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 --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
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("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-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("");
|
return Buffer.from("");
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await pool.prepareForTask(
|
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
|
||||||
"/tmp/wt",
|
expect(result.branch).toBe("fusion/fn-042-3");
|
||||||
"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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to git worktree prune when conflicting worktree no longer exists on disk", async () => {
|
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");
|
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]);
|
const cmds = mockedExecSync.mock.calls.map((c) => c[0]);
|
||||||
expect(cmds).toContain("git worktree prune");
|
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 () => {
|
it("throws when all suffixed names are exhausted in legacy rename mode", async () => {
|
||||||
mockedExistsSync.mockReturnValue(true);
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
|
||||||
mockedExecSync.mockImplementation((cmd: any) => {
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
const cmdStr = String(cmd);
|
const cmdStr = String(cmd);
|
||||||
if (cmdStr.includes("checkout -B")) {
|
if (cmdStr.includes("checkout -B")) {
|
||||||
const err: any = new Error("branch conflict");
|
const err: any = new Error("branch conflict");
|
||||||
err.stderr = Buffer.from(
|
err.stderr = Buffer.from("fatal: 'x' is already used by worktree at '/other/wt'");
|
||||||
`fatal: 'x' is already used by worktree at '/other/wt'`
|
|
||||||
);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
if (cmdStr === "git worktree list --porcelain") return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||||
return Buffer.from("abc123def456\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 --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
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("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");
|
|
||||||
}
|
|
||||||
return Buffer.from("");
|
return Buffer.from("");
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
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/);
|
||||||
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" }
|
||||||
| { kind: "stale-resolved" }
|
| { kind: "stale-resolved" }
|
||||||
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
|
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
|
||||||
| { kind: "live-foreign"; livePath: string }
|
| { kind: "live-foreign"; livePath: string; error: BranchConflictError };
|
||||||
| { kind: "live"; error: BranchConflictError };
|
|
||||||
|
|
||||||
export interface ListBranchRecoveryCandidatesInput {
|
export interface ListBranchRecoveryCandidatesInput {
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
@@ -287,38 +286,34 @@ export async function inspectBranchConflict(
|
|||||||
return { kind: "stale-resolved" };
|
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 existingTipSha = await revParse(input.repoDir, input.branchName);
|
||||||
const strandedCommits = await listStrandedCommits(input.repoDir, startPoint, 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 {
|
return {
|
||||||
kind: "live",
|
kind: "live-foreign",
|
||||||
|
livePath,
|
||||||
error: new BranchConflictError({
|
error: new BranchConflictError({
|
||||||
branchName: input.branchName,
|
branchName: input.branchName,
|
||||||
conflictingWorktreePath: input.conflictingWorktreePath,
|
conflictingWorktreePath: livePath,
|
||||||
existingTipSha,
|
existingTipSha,
|
||||||
strandedCommits,
|
strandedCommits,
|
||||||
startPoint,
|
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;
|
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) {
|
if (!acquisition.isResume && acquisition.source === "fresh" && settings.setupScript) {
|
||||||
const scriptCommand = settings.scripts?.[settings.setupScript];
|
const scriptCommand = settings.scripts?.[settings.setupScript];
|
||||||
if (scriptCommand) {
|
if (scriptCommand) {
|
||||||
@@ -7343,7 +7358,7 @@ and show an appropriate message to the user.\`
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!allowSiblingBranchRename) {
|
if (!allowSiblingBranchRename) {
|
||||||
throw inspection.error;
|
throw new Error(`Branch ${branch} conflict could not be auto-resolved`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const conflictStartPoint = branch;
|
const conflictStartPoint = branch;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export type GitMutationType =
|
|||||||
| "merge:start"
|
| "merge:start"
|
||||||
| "merge:resolve"
|
| "merge:resolve"
|
||||||
| "merge:audit-failure"
|
| "merge:audit-failure"
|
||||||
|
| "branch:auto-reclaim"
|
||||||
| "stash:push"
|
| "stash:push"
|
||||||
| "stash:pop";
|
| "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 { 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 type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||||
import { createLogger } from "./logger.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 { extractMissingWorktreePathFromSessionStartFailure, isMissingWorktreeSessionStartFailure, isRecoverableMissingWorktreeReviewFailure } from "./restart-recovery-coordinator.js";
|
||||||
import { classifyError, extractMissingModulePath, isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError } from "./transient-error-detector.js";
|
import { classifyError, extractMissingModulePath, isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError } from "./transient-error-detector.js";
|
||||||
|
import { inspectBranchConflict } from "./branch-conflicts.js";
|
||||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||||
|
|
||||||
const log = createLogger("self-healing");
|
const log = createLogger("self-healing");
|
||||||
@@ -374,6 +375,7 @@ export class SelfHealingManager {
|
|||||||
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) },
|
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) },
|
||||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks().then(() => undefined) },
|
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks().then(() => undefined) },
|
||||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().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) },
|
{ 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-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns() },
|
||||||
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() },
|
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() },
|
||||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
|
{ 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() },
|
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
|
||||||
];
|
];
|
||||||
for (const fn of batch2Fns) {
|
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
|
* Clear `blockedBy` on todo tasks whose blocker has reached a terminal or
|
||||||
* stuck state.
|
* stuck state.
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export interface AcquireTaskWorktreeResult {
|
|||||||
source: "existing" | "pool" | "fresh";
|
source: "existing" | "pool" | "fresh";
|
||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
isResume: boolean;
|
isResume: boolean;
|
||||||
|
reclaimed?: {
|
||||||
|
existingTipSha?: string;
|
||||||
|
strandedCommitCount?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function configuredCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string {
|
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();
|
const pooled = pool.acquire();
|
||||||
if (pooled) {
|
if (pooled) {
|
||||||
try {
|
try {
|
||||||
const actualBranch = await pool.prepareForTask(pooled, branchName, baseBranch ?? undefined, { allowSiblingBranchRename, repoDir: rootDir });
|
const preparedRaw = await pool.prepareForTask(pooled, branchName, baseBranch ?? undefined, {
|
||||||
worktreePath = pooled;
|
allowSiblingBranchRename,
|
||||||
branch = actualBranch;
|
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;
|
acquiredFromPool = true;
|
||||||
logger?.log(`Acquired worktree from pool: ${pooled}`);
|
logger?.log(`Acquired worktree from pool: ${worktreePath}`);
|
||||||
await store.updateTask(task.id, { worktree: worktreePath, branch: actualBranch });
|
await store.updateTask(task.id, { worktree: worktreePath, branch });
|
||||||
await audit?.git({ type: "worktree:reuse", target: worktreePath, metadata: { branch: actualBranch } });
|
await audit?.git({ type: "worktree:reuse", target: worktreePath, metadata: { branch, reclaimed: prepared.reclaimed } });
|
||||||
if (actualBranch !== branchName) {
|
if (prepared.reclaimed) {
|
||||||
logger?.log(`Branch conflict resolved: using ${actualBranch} instead of ${branchName}`);
|
await store.logEntry(task.id, `Acquired reclaimed worktree from pool: ${worktreePath} (${prepared.strandedCommitCount ?? 0} commits preserved)`, undefined, runContext);
|
||||||
await store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath} (branch conflict: using ${actualBranch})`, 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 {
|
} else {
|
||||||
await store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath}`, undefined, runContext);
|
await store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath}`, undefined, runContext);
|
||||||
}
|
}
|
||||||
const hydrated = await hydrate(worktreePath);
|
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) {
|
} catch (poolErr) {
|
||||||
pool.release(pooled);
|
pool.release(pooled);
|
||||||
if (isBranchConflictError(poolErr)) throw poolErr;
|
if (isBranchConflictError(poolErr)) throw poolErr;
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ function deriveTaskIdFromBranch(branchName: string): string {
|
|||||||
return match ? match[1].toUpperCase() : branchName.toUpperCase();
|
return match ? match[1].toUpperCase() : branchName.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PrepareForTaskResult = {
|
||||||
|
branch: string;
|
||||||
|
worktreePath: string;
|
||||||
|
reclaimed: boolean;
|
||||||
|
existingTipSha?: string;
|
||||||
|
strandedCommitCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class WorktreePool {
|
export class WorktreePool {
|
||||||
private idle = new Set<string>();
|
private idle = new Set<string>();
|
||||||
|
|
||||||
@@ -207,8 +215,8 @@ export class WorktreePool {
|
|||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
branchName: string,
|
branchName: string,
|
||||||
startPoint?: string,
|
startPoint?: string,
|
||||||
options?: { allowSiblingBranchRename?: boolean; repoDir?: string },
|
options?: { allowSiblingBranchRename?: boolean; repoDir?: string; requestingTaskId?: string },
|
||||||
): Promise<string> {
|
): Promise<PrepareForTaskResult> {
|
||||||
// Clean tracked modifications
|
// Clean tracked modifications
|
||||||
try {
|
try {
|
||||||
await execAsync("git checkout -- .", { cwd: worktreePath });
|
await execAsync("git checkout -- .", { cwd: worktreePath });
|
||||||
@@ -235,11 +243,11 @@ export class WorktreePool {
|
|||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
await assertCleanBranchAtBase(worktreePath, branchName, resolvedBase, taskId);
|
await assertCleanBranchAtBase(worktreePath, branchName, resolvedBase, taskId);
|
||||||
return branchName;
|
return { branch: branchName, worktreePath, reclaimed: false };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const execError = err instanceof Error ? err : new Error(String(err));
|
const execError = err instanceof Error ? err : new Error(String(err));
|
||||||
const stderr = "stderr" in execError && typeof execError.stderr === "string"
|
const stderr = "stderr" in execError
|
||||||
? execError.stderr.toString()
|
? String((execError as { stderr?: unknown }).stderr ?? execError.message)
|
||||||
: execError.message;
|
: execError.message;
|
||||||
const match = stderr.match(/already used by worktree at '([^']+)'/);
|
const match = stderr.match(/already used by worktree at '([^']+)'/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -255,18 +263,31 @@ export class WorktreePool {
|
|||||||
repoDir: options?.repoDir ?? worktreePath,
|
repoDir: options?.repoDir ?? worktreePath,
|
||||||
branchName,
|
branchName,
|
||||||
conflictingWorktreePath: conflictingPath,
|
conflictingWorktreePath: conflictingPath,
|
||||||
requestingTaskId: branchName,
|
requestingTaskId: options?.requestingTaskId ?? taskId,
|
||||||
startPoint: base,
|
startPoint: base,
|
||||||
});
|
});
|
||||||
if (inspection.kind === "stale") {
|
if (inspection.kind === "stale" || inspection.kind === "stale-resolved") {
|
||||||
await execAsync("git worktree prune", { cwd: worktreePath });
|
await execAsync("git worktree prune", { cwd: worktreePath });
|
||||||
await execAsync(checkoutCmd, { cwd: worktreePath });
|
await execAsync(checkoutCmd, { cwd: worktreePath });
|
||||||
await assertCleanBranchAtBase(worktreePath, branchName, resolvedBase, taskId);
|
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 (!options?.allowSiblingBranchRename) {
|
||||||
if (inspection.kind === "live") {
|
if (inspection.kind === "live-foreign") {
|
||||||
throw inspection.error;
|
throw inspection.error;
|
||||||
}
|
}
|
||||||
throw new Error(`Branch ${branchName} is already in use at ${conflictingPath}`);
|
throw new Error(`Branch ${branchName} is already in use at ${conflictingPath}`);
|
||||||
@@ -279,7 +300,7 @@ export class WorktreePool {
|
|||||||
try {
|
try {
|
||||||
await execAsync(suffixedCmd, { cwd: worktreePath });
|
await execAsync(suffixedCmd, { cwd: worktreePath });
|
||||||
await assertCleanBranchAtBase(worktreePath, suffixedName, resolvedBase, taskId);
|
await assertCleanBranchAtBase(worktreePath, suffixedName, resolvedBase, taskId);
|
||||||
return suffixedName;
|
return { branch: suffixedName, worktreePath, reclaimed: false };
|
||||||
} catch (suffixErr: unknown) {
|
} catch (suffixErr: unknown) {
|
||||||
const suffixExecError = suffixErr instanceof Error ? suffixErr : new Error(String(suffixErr));
|
const suffixExecError = suffixErr instanceof Error ? suffixErr : new Error(String(suffixErr));
|
||||||
const suffixStderr = "stderr" in suffixExecError && typeof suffixExecError.stderr === "string"
|
const suffixStderr = "stderr" in suffixExecError && typeof suffixExecError.stderr === "string"
|
||||||
|
|||||||
Reference in New Issue
Block a user