test: align workspace callback fixtures and gate (#3500)

## Summary
- update workspace worktree test stores for callback-based entry
mutations
- preserve validation, existing-entry, and singular-routing behavior in
the fakes
- canonicalize the macOS worktree path fixture before comparing
persisted state
- remove the stale inert-seam exception left after the review-column
callback became fully supplied

## Test plan
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/node-worktree-isolation.test.ts
src/__tests__/workspace-root-worktree-routing.test.ts
src/__tests__/worktree-acquisition.test.ts --silent=passed-only
--reporter=dot`
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/worktree-acquisition-workspace.test.ts
--silent=passed-only --reporter=dot`
- Directly changed engine workspace suite with PostgreSQL-dependent
blocks skipped: 338 passed, 4 skipped
- `pnpm --filter @fusion/engine typecheck`
- `node scripts/check-inert-flag-seams.mjs`
- `node --test scripts/__tests__/check-inert-flag-seams.test.mjs`
- `pnpm lint`
- `pnpm check:changesets`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved recovery of orphaned worktrees by consistently using
canonical paths.
* Strengthened worktree assignment and updates to preserve existing
entries and prevent stale task state.
* Improved synchronization and validation during concurrent workspace
updates.
* Refined self-healing recovery so branch metadata changes only when
necessary.
* Improved handling of stalled tasks, retry exhaustion, and clearing
obsolete worktree or branch information.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Phil Larson
2026-08-21 19:31:08 -07:00
committed by GitHub
parent 47dd536e36
commit 8fe69ac5c8
7 changed files with 287 additions and 75 deletions

View File

@@ -1,5 +1,6 @@
import { vi } from "vitest";
import type { Mock } from "vitest";
import type { Task } from "@fusion/core";
import { installTaskWorktreeIdentityGuard } from "../worktree/worktree-hooks.js";
import type * as ReviewerModule from "../execution/reviewer.js";
@@ -553,6 +554,13 @@ export function createMockStore() {
`getTask`/`updateTask` implementations replace these outright and are unaffected.
*/
const patches = new Map<string, Record<string, unknown>>();
/*
FNXC:EngineTests 2026-08-21-08:34:
The shared executor store fake must preserve TaskStore's per-task atomic merge contract. Queue merge
calls and re-read after an async callback so sibling merges and simulated external task updates cannot
be overwritten by a stale workspaceWorktrees snapshot.
*/
const workspaceMergeTails = new Map<string, Promise<void>>();
const applyPatch = (id: string, patch: Record<string, unknown> | undefined) => {
if (!patch || typeof patch !== "object") return;
patches.set(id, { ...(patches.get(id) ?? {}), ...patch });
@@ -672,17 +680,46 @@ export function createMockStore() {
applyPatch(id, patch);
return { ...(patches.get(id) ?? {}), id };
}),
mergeWorkspaceWorktreeEntry: vi.fn(async (id: string, repoRelPath: string, patch: Record<string, unknown>) => {
const live = await store.getTask(id);
const workspaceWorktrees = (live?.workspaceWorktrees ?? {}) as Record<string, unknown>;
const existing = workspaceWorktrees[repoRelPath];
applyPatch(id, {
workspaceWorktrees: {
...workspaceWorktrees,
[repoRelPath]: { ...(existing && typeof existing === "object" ? existing : {}), ...patch },
},
mergeWorkspaceWorktreeEntry: vi.fn((
id: string,
repoRelPath: string,
patch: Partial<NonNullable<Task["workspaceWorktrees"]>[string]>
| ((current: Task) => Promise<Partial<NonNullable<Task["workspaceWorktrees"]>[string]>>),
options?: {
requireExistingEntry?: boolean;
clearSingularWorktree?: boolean;
validateBeforePersist?: (current: Task) => Promise<void>;
},
) => {
const operation = (workspaceMergeTails.get(id) ?? Promise.resolve()).then(async () => {
const callbackTask = await store.getTask(id) as Task;
const callbackExisting = callbackTask.workspaceWorktrees?.[repoRelPath];
if (options?.requireExistingEntry && !callbackExisting) return callbackTask;
const resolvedPatch = typeof patch === "function" ? await patch(callbackTask) : patch;
const current = await store.getTask(id) as Task;
const workspaceWorktrees = current.workspaceWorktrees ?? {};
const existing = workspaceWorktrees[repoRelPath];
if (options?.requireExistingEntry && !existing) return current;
await options?.validateBeforePersist?.(current);
applyPatch(id, {
workspaceWorktrees: {
...workspaceWorktrees,
[repoRelPath]: { ...existing, ...resolvedPatch },
},
...(options?.clearSingularWorktree
? {
worktree: null,
branch: null,
branchWriteOrigin: "engine",
executionStartBranch: null,
baseCommitSha: null,
}
: {}),
});
return store.getTask(id);
});
return store.getTask(id);
workspaceMergeTails.set(id, operation.then(() => undefined, () => undefined));
return operation;
}),
updateWorkspaceReviewState: vi.fn(async (id: string, _revision: number, reviewRemediation: unknown) => {
const current = await store.getTask(id);

View File

@@ -66,6 +66,79 @@ function createWorktreeExecutor(store: any, rootDir: string, options: any = {})
}
describe("worktree workflow routing fixture", () => {
/*
FNXC:EngineTests 2026-08-21-08:34:
The shared executor fake is a production-contract seam: concurrent callback merges must preserve
every repository entry, missing required entries are no-ops, and workspace routing clears all
singular-checkout metadata.
FNXC:EngineTests 2026-08-21-09:29:
Cleared singular-checkout metadata must use null, matching the persisted TaskStore row shape;
undefined would let fixture-only behavior diverge from production reads.
*/
it("mirrors atomic workspace worktree merge semantics", async () => {
const store = createMockStore();
store._setRow("FN-workspace-merge", {
worktree: "/tmp/singular",
branch: "fusion/singular",
executionStartBranch: "main",
baseCommitSha: "base",
workspaceWorktrees: {},
});
const missing = await store.mergeWorkspaceWorktreeEntry(
"FN-workspace-merge",
"missing",
{ worktreePath: "/tmp/missing" },
{ requireExistingEntry: true },
);
expect(missing.workspaceWorktrees?.missing).toBeUndefined();
let releaseFirst!: () => void;
let markFirstStarted!: () => void;
const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
const firstStarted = new Promise<void>((resolve) => { markFirstStarted = resolve; });
const first = store.mergeWorkspaceWorktreeEntry(
"FN-workspace-merge",
"repo-a",
async () => {
markFirstStarted();
await firstGate;
return { worktreePath: "/tmp/repo-a", branch: "fusion/a" };
},
);
await firstStarted;
store._setRow("FN-workspace-merge", {
workspaceWorktrees: {
"repo-c": { worktreePath: "/tmp/repo-c", branch: "fusion/c" },
},
});
let secondStarted = false;
const second = store.mergeWorkspaceWorktreeEntry(
"FN-workspace-merge",
"repo-b",
async (freshTask: Task) => {
secondStarted = true;
expect(freshTask.workspaceWorktrees?.["repo-a"]?.worktreePath).toBe("/tmp/repo-a");
return { worktreePath: "/tmp/repo-b", branch: "fusion/b" };
},
{ clearSingularWorktree: true },
);
await Promise.resolve();
expect(secondStarted).toBe(false);
releaseFirst();
const [, result] = await Promise.all([first, second]);
expect(Object.keys(result.workspaceWorktrees ?? {}).sort()).toEqual(["repo-a", "repo-b", "repo-c"]);
expect(result).toEqual(expect.objectContaining({ branchWriteOrigin: "engine" }));
expect(result.worktree).toBeNull();
expect(result.branch).toBeNull();
expect(result.executionStartBranch).toBeNull();
expect(result.baseCommitSha).toBeNull();
});
it("selects its eligible executor from the role pool", async () => {
const store = createMockStore();
const fixture = createWorkflowRoutingAgentStore(store);

View File

@@ -2673,6 +2673,11 @@ describe("SelfHealingManager", () => {
expect(store.archiveTaskAndCleanup).not.toHaveBeenCalledWith("FN-100");
});
/*
FNXC:SelfHealing 2026-08-21-08:44:
Archive retry tests must drive past the prior failure budget on one manager instance. Separate
task IDs make same-reason exhaustion and failure-class reset independently observable.
*/
it("bounds same-reason archive failures and resets the budget when the failure class changes", async () => {
vi.setSystemTime(new Date("2026-01-04T00:00:00.000Z"));
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -2680,28 +2685,29 @@ describe("SelfHealingManager", () => {
autoArchiveDoneAfterMs: 24 * 60 * 60 * 1000,
doneAutoArchiveDays: 0,
} as unknown as Settings);
const stale = [{ id: "FN-RETRY", column: "done", columnMovedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" }];
const stale = [
{ id: "FN-BOUNDED", column: "done", columnMovedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" },
{ id: "FN-CHANGING", column: "done", columnMovedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" },
];
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue(stale);
(store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("disk busy"));
const changingFailures = [
new Error("disk busy"),
new Error("disk busy"),
Object.assign(new Error("live"), { name: "TaskIsLiveError" }),
new Error("disk busy"),
new Error("disk busy"),
new Error("disk busy"),
];
(store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>).mockImplementation(async (id: string) => {
if (id === "FN-BOUNDED") throw new Error("disk busy");
throw changingFailures.shift() ?? new Error("disk busy");
});
for (let index = 0; index < 10; index++) await manager.archiveStaleDoneTasks();
expect(store.archiveTaskAndCleanup).toHaveBeenCalledTimes(3);
(store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>).mockClear();
const taskLive = Object.assign(new Error("live"), { name: "TaskIsLiveError" });
(store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>)
.mockRejectedValueOnce(new Error("disk busy"))
.mockRejectedValueOnce(taskLive)
.mockRejectedValueOnce(new Error("disk busy"));
const managerWithChangingFailure = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
await managerWithChangingFailure.archiveStaleDoneTasks();
await managerWithChangingFailure.archiveStaleDoneTasks();
await managerWithChangingFailure.archiveStaleDoneTasks();
expect(store.archiveTaskAndCleanup).toHaveBeenCalledTimes(3);
managerWithChangingFailure.stop();
const calls = (store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.filter(([id]) => id === "FN-BOUNDED")).toHaveLength(3);
expect(calls.filter(([id]) => id === "FN-CHANGING")).toHaveLength(6);
});
it("escalates an exhausted archive budget once without letting log or audit failures stop other archives", async () => {
@@ -2742,23 +2748,29 @@ describe("SelfHealingManager", () => {
autoArchiveDoneAfterMs: 24 * 60 * 60 * 1000,
doneAutoArchiveDays: 0,
} as unknown as Settings);
const stale = [{ id: "FN-RESET", column: "done", columnMovedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" }];
const successTask = { id: "FN-RESET-SUCCESS", column: "done", columnMovedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" };
const candidateTask = { id: "FN-RESET-CANDIDATE", column: "done", columnMovedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" };
(store.listTasks as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(stale);
(store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>)
.mockRejectedValueOnce(new Error("disk busy"))
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error("disk busy"));
.mockResolvedValueOnce([successTask, candidateTask])
.mockResolvedValueOnce([successTask, candidateTask])
.mockResolvedValueOnce([successTask])
.mockResolvedValueOnce([successTask, candidateTask])
.mockResolvedValueOnce([successTask, candidateTask])
.mockResolvedValueOnce([successTask, candidateTask]);
let successCalls = 0;
(store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>).mockImplementation(async (id: string) => {
if (id === "FN-RESET-SUCCESS") {
successCalls++;
if (successCalls === 3) return {};
}
throw new Error("disk busy");
});
await manager.archiveStaleDoneTasks();
await manager.archiveStaleDoneTasks();
await manager.archiveStaleDoneTasks();
await manager.archiveStaleDoneTasks();
for (let index = 0; index < 6; index++) await manager.archiveStaleDoneTasks();
expect(store.archiveTaskAndCleanup).toHaveBeenCalledTimes(3);
const calls = (store.archiveTaskAndCleanup as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.filter(([id]) => id === "FN-RESET-SUCCESS")).toHaveLength(6);
expect(calls.filter(([id]) => id === "FN-RESET-CANDIDATE")).toHaveLength(5);
});
it("skips stale done lineage parents, including complete children, without blocking unrelated archives", async () => {
@@ -4251,6 +4263,11 @@ describe("SelfHealingManager", () => {
worktree: null,
sessionFile: null,
}));
const workspacePatch = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.find(([taskId]) => taskId === "FN-7802-WORKSPACE")?.[1];
// FNXC:WorkspaceRecovery 2026-08-21-08:44: Prove recovery emitted the workspace patch before asserting that it preserves repository routing.
expect(workspacePatch).toBeDefined();
expect(workspacePatch).not.toHaveProperty("branch");
expect(store.moveTask).toHaveBeenCalledWith("FN-7802-WORKSPACE", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:reconcile-missing-worktree-merge-active",

View File

@@ -19,6 +19,12 @@ const describeIfGit = hasGit ? describe : describe.skip;
function makeFakeStore(task: Task): { store: TaskStore; current: () => Task } {
let current = task;
/*
FNXC:WorkspaceRootRouting 2026-08-21-08:34:
This focused routing fake mirrors TaskStore's per-task merge lock and post-callback authoritative
re-read so callback-based acquisitions cannot clobber sibling entries or external task updates.
*/
let workspaceMergeTail = Promise.resolve();
const store = {
async updateTask(id: string, patch: Partial<Task>): Promise<void> {
if (id === current.id) current = { ...current, ...patch };
@@ -26,20 +32,46 @@ function makeFakeStore(task: Task): { store: TaskStore; current: () => Task } {
async mergeWorkspaceWorktreeEntry(
id: string,
repoRelPath: string,
patch: Partial<NonNullable<Task["workspaceWorktrees"]>[string]>,
options?: { clearSingularWorktree?: boolean },
patch: Partial<NonNullable<Task["workspaceWorktrees"]>[string]>
| ((freshTask: Task) => Promise<Partial<NonNullable<Task["workspaceWorktrees"]>[string]>>),
options?: {
requireExistingEntry?: boolean;
clearSingularWorktree?: boolean;
validateBeforePersist?: (freshTask: Task) => Promise<void>;
},
): Promise<Task> {
if (id !== current.id) throw new Error(`Task ${id} not found`);
const existing = current.workspaceWorktrees?.[repoRelPath];
current = {
...current,
workspaceWorktrees: {
...(current.workspaceWorktrees ?? {}),
[repoRelPath]: { ...existing, ...patch },
},
...(options?.clearSingularWorktree ? { worktree: undefined, branch: undefined } : {}),
};
return current;
const operation = workspaceMergeTail.then(async () => {
if (id !== current.id) throw new Error(`Task ${id} not found`);
const existing = current.workspaceWorktrees?.[repoRelPath];
if (options?.requireExistingEntry && !existing) return current;
const callbackTask = current;
const resolvedPatch = typeof patch === "function" ? await patch(callbackTask) : patch;
const freshExisting = current.workspaceWorktrees?.[repoRelPath];
if (options?.requireExistingEntry && !freshExisting) return current;
await options?.validateBeforePersist?.(current);
current = {
...current,
workspaceWorktrees: {
...(current.workspaceWorktrees ?? {}),
[repoRelPath]: {
...freshExisting,
...resolvedPatch,
} as NonNullable<Task["workspaceWorktrees"]>[string],
},
...(options?.clearSingularWorktree
? {
worktree: null,
branch: null,
branchWriteOrigin: "engine" as const,
executionStartBranch: null,
baseCommitSha: null,
}
: {}),
};
return current;
});
workspaceMergeTail = operation.then(() => undefined, () => undefined);
return operation;
},
async logEntry(): Promise<void> {},
async getTask(id: string): Promise<Task> {
@@ -65,6 +97,73 @@ function makeTask(id: string): Task {
} as Task;
}
describe("workspace root routing store fake", () => {
/*
FNXC:WorkspaceRootRouting 2026-08-21-08:34:
The focused routing fake must hold the same concurrent-merge invariant as the shared executor fake;
exercising both surfaces prevents one fixture from silently reverting to stale snapshot writes.
FNXC:WorkspaceRootRouting 2026-08-21-16:42:
Clearing singular routing metadata must return null on this focused fake, matching persisted TaskStore
reads and the shared executor fake instead of exposing fixture-only undefined values.
*/
it("serializes callback merges without dropping sibling repository entries", async () => {
const task = makeTask("FN-routing-merge");
Object.assign(task, {
worktree: "/tmp/singular",
branch: "fusion/singular",
executionStartBranch: "main",
baseCommitSha: "base",
});
const { store, current } = makeFakeStore(task);
let releaseFirst!: () => void;
let markFirstStarted!: () => void;
const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
const firstStarted = new Promise<void>((resolve) => { markFirstStarted = resolve; });
const first = store.mergeWorkspaceWorktreeEntry(
"FN-routing-merge",
"repo-a",
async () => {
markFirstStarted();
await firstGate;
return { worktreePath: "/tmp/repo-a", branch: "fusion/a" };
},
);
await firstStarted;
await store.updateTask("FN-routing-merge", {
workspaceWorktrees: {
"repo-c": { worktreePath: "/tmp/repo-c", branch: "fusion/c" },
},
});
let secondStarted = false;
const second = store.mergeWorkspaceWorktreeEntry(
"FN-routing-merge",
"repo-b",
async (freshTask) => {
secondStarted = true;
expect(freshTask.workspaceWorktrees?.["repo-a"]?.worktreePath).toBe("/tmp/repo-a");
return { worktreePath: "/tmp/repo-b", branch: "fusion/b" };
},
{ clearSingularWorktree: true },
);
await Promise.resolve();
expect(secondStarted).toBe(false);
releaseFirst();
await Promise.all([first, second]);
expect(Object.keys(current().workspaceWorktrees ?? {}).sort()).toEqual(["repo-a", "repo-b", "repo-c"]);
expect(current()).toEqual(expect.objectContaining({
worktree: null,
branch: null,
branchWriteOrigin: "engine",
executionStartBranch: null,
baseCommitSha: null,
}));
});
});
const settings: Partial<Settings> = {
worktreeNaming: "task-id",
commitMsgHookEnabled: true,
@@ -127,7 +226,7 @@ describeIfGit("FN-034 workspace root worktree routing", { timeout: 60_000 }, ()
expect(result.coordinatorWorktreePath).toBe(result.task.workspaceWorktrees?.["repo-b"]?.worktreePath);
expect(result.coordinatorWorktreePath).not.toContain(join(fixture.rootDir, ".worktrees"));
expect(result.task.worktree).toBeUndefined();
expect(result.task.worktree).toBeNull();
});
it("rejects a stale review target instead of falling back to another repository", async () => {

View File

@@ -244,6 +244,7 @@ describe("acquireTaskWorktree", () => {
writeFileSync(join(orphanedPath, "preserved.txt"), "keep this checkout\n", "utf-8");
git(orphanedPath, "git add preserved.txt");
git(orphanedPath, 'git commit -m "FN-1: preserved pre-fix work"');
const canonicalOrphanedPath = realpathSync(orphanedPath);
const preservedTip = git(orphanedPath, "git rev-parse HEAD");
const createWorktree = vi.fn();
@@ -255,11 +256,11 @@ describe("acquireTaskWorktree", () => {
createWorktree,
});
expect(result).toMatchObject({ worktreePath: orphanedPath, branch: "fusion/fn-1", source: "existing" });
expect(result).toMatchObject({ worktreePath: canonicalOrphanedPath, branch: "fusion/fn-1", source: "existing" });
expect(git(orphanedPath, "git rev-parse HEAD")).toBe(preservedTip);
expect(createWorktree).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-1", {
worktree: orphanedPath,
worktree: canonicalOrphanedPath,
branch: "fusion/fn-1",
branchWriteOrigin: "engine",
});

View File

@@ -91,23 +91,7 @@ export function effectiveArgCount(args) {
const PREFILTER = /(olumnFlags|ifecycleColumns|eviewColumns|erminalColumns|lannerLanes|isplayColumnOptions)/;
/** Known-unsupplied seams, each with why it is tolerated. Shrink this list; never grow it casually. */
const ALLOWED = new Map([
/*
TEMPORARY — real offenders in packages owned by other batches, reported to them rather than edited
from outside. Remove each entry when that batch wires or deletes the parameter; the check will then
start guarding those files too. All three are the same shape this check exists to catch.
*/
[
"isRecoverableMissingWorktreeReviewFailure",
"No production caller; 5 test call sites. The previous entry blamed the scanner for excluding "
+ "__tests__ — that reason was wrong, the scan now reads tests and the count is real. The two "
+ "SIBLINGS it delegates to (`...WithProgress` / `...NoProgress`) are the live pair, called from "
+ "self-healing.ts and both supplying `reviewColumns`. This is the convenience wrapper over them, "
+ "kept as a public predicate and exercised only by its own tests. Engine-owned; left alone.",
],
]);
const ALLOWED = new Map([]);
/*
PARTIAL-SUPPLY exemptions are keyed by CALL SITE, not by function name.

View File

@@ -7,6 +7,7 @@
"packages/core/src/task-store/task-update.ts": 1,
"packages/engine/src/merge/auto-merge-finalization.ts": 1,
"packages/engine/src/project-engine.ts": 1,
"packages/engine/src/self-healing.ts": 1,
"packages/engine/src/runtimes/in-process-runtime.ts": 1,
"packages/engine/src/self-healing.ts": 1,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 1,