Files
fusion/packages/engine/src/__tests__/executor-worktree-conflict.test.ts
gsxdsm cb57093d03 refactor: domain folder layout (types, API, core, engine) (#2398)
## Summary

Wave 17 organizes Fusion into **domain folders** (stacks on #2397).

### Layout
- **core/types/** — board, task, agents, settings, merge, workflow,
mesh, …
- **core/src/** — agents, ai, async-stores, workflows, tasks, config,
db, …
- **dashboard/app/api/** — client, tasks, agents, git, missions,
planning, …
- **engine/src/** — agents, auth, execution, merge, missions, overseer,
worktree, …

Root keepers retained for large entrypoints (`store.ts`, `executor.ts`,
`merger.ts`, …).

Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy)
stay stable.

## Test plan
- [x] `@fusion/core` typecheck
- [x] `@fusion/engine` typecheck (pre-existing playwright-core noise
only)
- [ ] CI merge gate

**Stack:** #2394 → #2397 → **this PR**
2026-08-03 00:20:53 -07:00

294 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { activeSessionRegistry } from "../agents/active-session-registry.js";
import { ActiveSessionWorktreeRemovalError } from "../worktree/worktree-backend.js";
import * as worktreePoolModule from "../worktree/worktree-pool.js";
import * as branchConflictModule from "../execution/branch-conflicts.js";
import { createMockStore, mockedGenerateWorktreeName, resetExecutorMocks } from "./executor-test-helpers.js";
const CONFLICT_PATH = "/tmp/test/.worktrees/stale-self-owned";
describe("FN-4973: executor worktree conflict cleanup", () => {
beforeEach(() => {
resetExecutorMocks();
activeSessionRegistry.clear();
});
it("clears stale self-owned registry entry before removal", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
// FN-5256: backdate so the new min-idle window doesn't refuse the reconcile.
(activeSessionRegistry.lookupByPath(CONFLICT_PATH) as any).registeredAt = 0;
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(true);
expect(removeSpy).toHaveBeenCalled();
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)).toBeNull();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-4973",
"Cleared stale self-owned active-session entry before remove",
CONFLICT_PATH,
);
});
it("does not reconcile when same-task in-memory binding is live and refuses removal", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
(executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-4973",
kind: "executor",
ownerKey: "FN-4973",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(false);
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)?.taskId).toBe("FN-4973");
});
it("defers out-of-root reclaim instead of moving a live same-task checkout", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
const outsidePath = "/tmp/legacy-worktrees/recover-fn-8400";
const targetPath = "/tmp/test/.worktrees/recover-fn-8400";
(executor as any).addActiveWorktree("FN-8400", outsidePath);
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: outsidePath,
tipSha: "abc123",
taskAttributedCommitCount: 1,
strandedCommits: [{ sha: "abc123", subject: "fix(FN-8400): preserve implementation" }],
} as any);
const relocate = vi.spyOn(worktreePoolModule, "relocateReclaimableWorktreeIntoRoot");
const result = await (executor as any).handleWorktreeConflict(
outsidePath,
"fusion/fn-8400",
targetPath,
"FN-8400",
"main",
0,
false,
{},
);
expect(result).toEqual({ path: outsidePath, branch: "fusion/fn-8400" });
expect(relocate).toHaveBeenCalledWith(expect.objectContaining({
sourcePath: outsidePath,
targetPath,
taskId: "FN-8400",
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-8400",
expect.stringContaining("deferred relocation of active preserved worktree"),
outsidePath,
);
});
it("does not reconcile foreign-task registry entries and keeps refusal behavior", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-OTHER",
kind: "executor",
ownerKey: "FN-OTHER",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(false);
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)?.taskId).toBe("FN-OTHER");
});
it("recovers a genuine orphan dir when git reports 'is not a working tree'", async () => {
// FN-6782: a leaked orphan dir (dir on disk, admin entry gone) makes `git worktree remove`
// fail with "is not a working tree". The stale-path recovery should prune, clean up, and
// return true so fresh creation can proceed.
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new Error("fatal: '/tmp/test/.worktrees/stale-self-owned' is not a working tree"),
);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(true);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-4973",
expect.stringContaining("Cleaned up stale conflicting worktree"),
CONFLICT_PATH,
);
});
it("refuses stale-path cleanup (no force-rm) for a conflict path outside .worktrees/", async () => {
// Security regression: the recovery's rm must be bounded to .worktrees/. A git admin entry
// can point anywhere; an out-of-bounds path must be refused, not force-removed.
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
const OUTSIDE_PATH = "/tmp/test/not-worktrees/escapee";
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new Error("fatal: '/tmp/test/not-worktrees/escapee' is not a working tree"),
);
const result = await (executor as any).cleanupConflictingWorktree(OUTSIDE_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(false);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-4973",
expect.stringContaining("Refused stale-path cleanup"),
OUTSIDE_PATH,
);
});
it("falls back to a fresh sibling branch for a DB-only live owner", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
executorAllowSiblingBranchRename: true,
});
store.listTasks.mockResolvedValue([
{ id: "FN-LIVE", worktree: CONFLICT_PATH, column: "in-progress", paused: false },
]);
mockedGenerateWorktreeName.mockReturnValueOnce("fresh-eagle");
const executor = new TaskExecutor(store, "/tmp/test");
const createSpy = vi.spyOn(executor as any, "tryCreateWorktree").mockResolvedValue({
path: "/tmp/test/.worktrees/fresh-eagle",
branch: "fusion/fn-4973-2",
});
const result = await (executor as any).handleWorktreeConflict(
CONFLICT_PATH,
"fusion/fn-4973",
"/tmp/test/.worktrees/stale-self-owned",
"FN-4973",
"main",
0,
true,
await store.getSettings(),
);
expect(result).toEqual({ path: "/tmp/test/.worktrees/fresh-eagle", branch: "fusion/fn-4973-2" });
expect(createSpy).toHaveBeenCalledWith(
"fusion/fn-4973-2",
"/tmp/test/.worktrees/fresh-eagle",
"FN-4973",
"fusion/fn-4973",
0,
0,
true,
expect.any(Object),
);
});
it("keeps non-live cleanup failures unrecoverable", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(false);
const result = await (executor as any).handleWorktreeConflict(
CONFLICT_PATH,
"fusion/fn-4973",
"/tmp/test/.worktrees/stale-self-owned",
"FN-4973",
"main",
0,
true,
await store.getSettings(),
);
expect(result).toBeNull();
});
it("bounds fresh sibling fallback when every generated branch is already used", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "workflow-step", ownerKey: "FN-4973/workflow-step" });
(executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH);
mockedGenerateWorktreeName
.mockReturnValueOnce("fresh-2")
.mockReturnValueOnce("fresh-3")
.mockReturnValueOnce("fresh-4")
.mockReturnValueOnce("fresh-5")
.mockReturnValueOnce("fresh-6");
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-4973",
kind: "workflow-step",
ownerKey: "FN-4973/workflow-step",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
vi.spyOn(executor as any, "tryCreateWorktree").mockImplementation(async (branch: string) => {
throw new Error(`fatal: '${branch}' is already used by worktree at '/tmp/test/.worktrees/other'`);
});
await expect((executor as any).handleWorktreeConflict(
CONFLICT_PATH,
"fusion/fn-4973",
"/tmp/test/.worktrees/stale-self-owned",
"FN-4973",
"main",
0,
true,
await store.getSettings(),
)).rejects.toThrow(/live conflicting worktree .* was preserved and suffixes -2 through -6/);
});
it("reconciles once on race-window ActiveSessionWorktreeRemovalError then retries removal", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree");
removeSpy
.mockImplementationOnce(async () => {
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
// FN-5256: backdate so the post-throw reconcile is not refused by min-idle.
(activeSessionRegistry.lookupByPath(CONFLICT_PATH) as any).registeredAt = 0;
throw new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-4973",
kind: "executor",
ownerKey: "FN-4973",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
});
})
.mockResolvedValueOnce(undefined);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(true);
expect(removeSpy).toHaveBeenCalledTimes(2);
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)).toBeNull();
});
});