In workspace mode (loadWorkspaceConfig present), the executor now skips the
root acquireTaskWorktree({rootDir}) and every intervening rootDir git preflight
(base-commit capture, contamination, identity-guard, verifyWorktreeInvariants),
runs the agent session rooted at the non-git workspace root (cwd=rootDir,
browse-only; task.worktree never set), and tracks activeWorktrees as a per-task
Set<path>. scopePromptToWorktree is a no-op in workspace mode. The non-workspace
path is unchanged (every change branches on this.workspaceConfig; a single-repo
task holds a one-element Set).
Converted every activeWorktrees consumer to membership semantics (feasibility-
verified list): findActiveWorktreeOwner, hasActiveWorktreeBinding, the FN-6736
phantom-binding reclaim, listWorktreeHolders (flat-maps a Set into N holder rows
— verified the FN-6782 reaper keys off taskId only, so slot accounting is
unaffected), the conflict-set iteration, the three deleteActive* unregister
resolvers (loop every path), cleanup, getWorktreePath (undefined for a
multi-worktree workspace task), and the verifyWorktreeInvariants singular
resolution (gated off in workspace mode — per-repo verify returns in Phase B).
Rewrote executor-workspace.test.ts from vi.mock-the-subject to a real two-repo
git fixture harness (_workspace-fixture.ts, shared with later units), 13 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
6.6 KiB
TypeScript
153 lines
6.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import "./executor-test-helpers.js";
|
|
import { TaskExecutor } from "../executor.js";
|
|
import { activeSessionRegistry } from "../active-session-registry.js";
|
|
import { ActiveSessionWorktreeRemovalError } from "../worktree-backend.js";
|
|
import * as worktreePoolModule from "../worktree-pool.js";
|
|
import { createMockStore, 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("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("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();
|
|
});
|
|
});
|