Ensure shared branch-group flows consistently derive and acquire per-task working branches. - update engine merge/executor/scheduler/self-healing paths to resolve task-scoped working branch names instead of shared branch-group names - adjust worktree acquisition and already-merged detection logic to use the corrected branch resolution - add regression coverage for shared-branch-group working-branch behavior, worktree acquisition, and worktree name derivation - add a patch changeset for @runfusion/fusion Files changed: .changeset/fn-5818-shared-branch-working-branch.md | 5 ++ .../shared-branch-group-working-branch.test.ts | 53 ++++++++++++++++++++++ .../src/__tests__/worktree-acquisition.test.ts | 52 +++++++++++++++++++++ .../engine/src/__tests__/worktree-names.test.ts | 20 +++++++- packages/engine/src/already-merged-detector.ts | 6 +-- packages/engine/src/executor.ts | 10 ++-- packages/engine/src/merger-ai.ts | 4 +- packages/engine/src/merger.ts | 16 +++---- packages/engine/src/scheduler.ts | 6 +-- packages/engine/src/self-healing.ts | 12 ++--- packages/engine/src/worktree-acquisition.ts | 4 +- packages/engine/src/worktree-names.ts | 9 +++- 12 files changed, 166 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-5818 Fusion-Task-Lineage: c7eb1ec4-971a-49ad-9156-5c0349c629aa
431 lines
17 KiB
TypeScript
431 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { promisify } from "node:util";
|
|
import { acquireTaskWorktree } from "../worktree-acquisition.js";
|
|
import { classifyTaskWorktree, PoolDoubleLeaseError } from "../worktree-pool.js";
|
|
import * as desktopArtifacts from "../worktree-desktop-artifacts.js";
|
|
import * as branchConflicts from "../branch-conflicts.js";
|
|
|
|
vi.mock("../worktree-pool.js", async () => {
|
|
const actual = await vi.importActual<any>("../worktree-pool.js");
|
|
return {
|
|
...actual,
|
|
classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }),
|
|
isInsideWorktreesDir: vi.fn().mockReturnValue(true),
|
|
};
|
|
});
|
|
|
|
vi.mock("../branch-conflicts.js", async () => {
|
|
const actual = await vi.importActual<any>("../branch-conflicts.js");
|
|
return {
|
|
...actual,
|
|
classifyBootstrapMisbinding: vi.fn().mockResolvedValue({
|
|
isBootstrapMisbinding: false,
|
|
ownCommitCount: 0,
|
|
foreignCommitCount: 0,
|
|
nonAttributedCount: 0,
|
|
}),
|
|
reanchorBranchToBase: vi.fn().mockResolvedValue({ previousTipSha: "abc", newTipSha: "def" }),
|
|
};
|
|
});
|
|
|
|
vi.mock("../worktree-db-hydrate.js", () => ({
|
|
hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1 }),
|
|
}));
|
|
|
|
vi.mock("../worktree-desktop-artifacts.js", () => ({
|
|
removeDesktopBuildArtifacts: vi.fn().mockResolvedValue({ removed: [], skipped: [], failures: [] }),
|
|
}));
|
|
|
|
describe("acquireTaskWorktree", () => {
|
|
const task = {
|
|
id: "FN-1",
|
|
title: "Task",
|
|
description: "Desc",
|
|
branch: null,
|
|
worktree: null,
|
|
} as any;
|
|
|
|
let store: any;
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(desktopArtifacts.removeDesktopBuildArtifacts).mockResolvedValue({ removed: [], skipped: [], failures: [] });
|
|
store = {
|
|
updateTask: vi.fn().mockResolvedValue(undefined),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
it("reuses existing usable worktree", async () => {
|
|
const result = await acquireTaskWorktree({
|
|
task: { ...task, worktree: process.cwd(), branch: "fusion/fn-1" },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
createWorktree: vi.fn(),
|
|
});
|
|
expect(result.source).toBe("existing");
|
|
expect(result.worktreePath).toBe(process.cwd());
|
|
});
|
|
|
|
// Regression: FN-5475 — when a resumed worktree's branch was created from
|
|
// a poisoned local-main tip carrying a sibling task's commits and has zero
|
|
// commits of its own, acquireTaskWorktree must re-anchor inline so the
|
|
// executor preflight doesn't pause on contamination forever.
|
|
it("re-anchors a resumed branch when classified as bootstrap-misbinding", async () => {
|
|
const audit = { git: vi.fn().mockResolvedValue(undefined), filesystem: vi.fn() } as any;
|
|
vi.mocked(branchConflicts.classifyBootstrapMisbinding).mockResolvedValueOnce({
|
|
isBootstrapMisbinding: true,
|
|
ownCommitCount: 0,
|
|
foreignCommitCount: 2,
|
|
nonAttributedCount: 0,
|
|
});
|
|
|
|
const result = await acquireTaskWorktree({
|
|
task: { ...task, worktree: process.cwd(), branch: "fusion/fn-1" },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
audit,
|
|
createWorktree: vi.fn(),
|
|
});
|
|
|
|
expect(result.source).toBe("existing");
|
|
expect(vi.mocked(branchConflicts.reanchorBranchToBase)).toHaveBeenCalledTimes(1);
|
|
expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: "branch:reanchor",
|
|
metadata: expect.objectContaining({ trigger: "resume-misbinding" }),
|
|
}));
|
|
});
|
|
|
|
it("does not re-anchor a resumed branch when not misbound", async () => {
|
|
const result = await acquireTaskWorktree({
|
|
task: { ...task, worktree: process.cwd(), branch: "fusion/fn-1" },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
createWorktree: vi.fn(),
|
|
});
|
|
expect(result.source).toBe("existing");
|
|
expect(vi.mocked(branchConflicts.reanchorBranchToBase)).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("derives distinct per-task working branches for shared branch-group members", async () => {
|
|
const createWorktree = vi.fn(async (branchName: string, worktreePath: string) => ({ path: worktreePath, branch: branchName }));
|
|
const sharedBranch = "clionboarding";
|
|
const sharedContext = { assignmentMode: "shared", groupId: "BG-1", source: "planning" } as const;
|
|
|
|
const [first, second] = await Promise.all([
|
|
acquireTaskWorktree({
|
|
task: { ...task, id: "FN-100", worktree: null, branch: sharedBranch, branchContext: sharedContext },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
createWorktree,
|
|
}),
|
|
acquireTaskWorktree({
|
|
task: { ...task, id: "FN-101", worktree: null, branch: sharedBranch, branchContext: sharedContext },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
createWorktree,
|
|
}),
|
|
]);
|
|
|
|
expect(first.branch).toBe("fusion/fn-100");
|
|
expect(second.branch).toBe("fusion/fn-101");
|
|
expect(first.branch).not.toBe(second.branch);
|
|
expect(createWorktree).toHaveBeenCalledWith("fusion/fn-100", expect.any(String), "FN-100", undefined, false);
|
|
expect(createWorktree).toHaveBeenCalledWith("fusion/fn-101", expect.any(String), "FN-101", undefined, false);
|
|
});
|
|
|
|
it("keeps per-task-derived and ungrouped branch derivation unchanged", async () => {
|
|
const createWorktree = vi.fn(async (branchName: string, worktreePath: string) => ({ path: worktreePath, branch: branchName }));
|
|
|
|
const perTaskDerived = await acquireTaskWorktree({
|
|
task: { ...task, id: "FN-102", worktree: null, branch: "fusion/custom-derived", branchContext: { assignmentMode: "per-task-derived", groupId: "BG-1", source: "planning" } },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
createWorktree,
|
|
});
|
|
|
|
const ungrouped = await acquireTaskWorktree({
|
|
task: { ...task, id: "FN-103", worktree: null, branch: null },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
createWorktree,
|
|
});
|
|
|
|
expect(perTaskDerived.branch).toBe("fusion/custom-derived");
|
|
expect(ungrouped.branch).toBe("fusion/fn-103");
|
|
});
|
|
|
|
it("acquires from pool when enabled", async () => {
|
|
const prepareForTask = vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath: "/tmp/pooled", reclaimed: false });
|
|
const release = vi.fn();
|
|
const result = await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { recycleWorktrees: true } as any,
|
|
pool: {
|
|
acquire: (_taskId: string) => "/tmp/pooled",
|
|
prepareForTask,
|
|
release,
|
|
} as any,
|
|
createWorktree: vi.fn(),
|
|
});
|
|
expect(release).not.toHaveBeenCalled();
|
|
expect(result.source).toBe("pool");
|
|
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(desktopArtifacts.removeDesktopBuildArtifacts).toHaveBeenCalledWith("/tmp/pooled", undefined);
|
|
});
|
|
|
|
it("releases acquired pooled worktree when prepareForTask returns reclaimed path", async () => {
|
|
const release = vi.fn();
|
|
await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { recycleWorktrees: true } as any,
|
|
pool: {
|
|
acquire: (_taskId: string) => "/tmp/pooled",
|
|
prepareForTask: vi.fn().mockResolvedValue({
|
|
branch: "fusion/fn-1",
|
|
worktreePath: "/tmp/live-existing",
|
|
reclaimed: true,
|
|
existingTipSha: "abc123",
|
|
strandedCommitCount: 2,
|
|
}),
|
|
release,
|
|
} as any,
|
|
createWorktree: vi.fn(),
|
|
});
|
|
|
|
expect(release).toHaveBeenCalledWith("/tmp/pooled", "FN-1");
|
|
});
|
|
|
|
it("falls through to fresh creation when pooled worktree is incomplete and emits detection audit", async () => {
|
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({ ok: false, classification: "incomplete", reason: "missing or invalid .git metadata" });
|
|
const createWorktree = vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" });
|
|
const auditGit = vi.fn().mockResolvedValue(undefined);
|
|
const remove = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const result = await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { recycleWorktrees: true } as any,
|
|
pool: {
|
|
acquire: (_taskId: string) => "/tmp/pooled",
|
|
prepareForTask: vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath: "/tmp/pooled", reclaimed: false }),
|
|
release: vi.fn(),
|
|
} as any,
|
|
createWorktree,
|
|
audit: { git: auditGit } as any,
|
|
backend: { kind: "native", create: vi.fn(), remove } as any,
|
|
});
|
|
|
|
expect(result.source).toBe("fresh");
|
|
expect(auditGit).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: "worktree:incomplete-detected",
|
|
metadata: expect.objectContaining({ classification: "incomplete", source: "pool-acquire" }),
|
|
}));
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", expect.stringContaining("Pool returned incomplete worktree"), undefined, undefined);
|
|
expect(store.logEntry).not.toHaveBeenCalledWith("FN-1", expect.stringMatching(/Refusing to start coding agent/), expect.anything(), expect.anything());
|
|
});
|
|
|
|
it("emits resume detection audit and clears session file when assigned worktree is unregistered", async () => {
|
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({ ok: false, classification: "unregistered", reason: "not registered in git worktree list" });
|
|
const createWorktree = vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" });
|
|
const auditGit = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await acquireTaskWorktree({
|
|
task: { ...task, worktree: process.cwd(), branch: "fusion/fn-1", sessionFile: "/tmp/session.json" },
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {} as any,
|
|
createWorktree,
|
|
audit: { git: auditGit } as any,
|
|
});
|
|
|
|
expect(auditGit).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: "worktree:incomplete-detected",
|
|
metadata: expect.objectContaining({ classification: "unregistered", source: "resume" }),
|
|
}));
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: null, branch: null, sessionFile: null });
|
|
});
|
|
|
|
it("falls through to fresh creation when pool acquire throws PoolDoubleLeaseError", async () => {
|
|
const createWorktree = vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" });
|
|
const result = await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { recycleWorktrees: true } as any,
|
|
pool: {
|
|
acquire: () => {
|
|
throw new PoolDoubleLeaseError("/tmp/pooled", "FN-OTHER", "FN-1", "acquire");
|
|
},
|
|
prepareForTask: vi.fn(),
|
|
release: vi.fn(),
|
|
} as any,
|
|
createWorktree,
|
|
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
expect(result.source).toBe("fresh");
|
|
expect(createWorktree).toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", expect.stringContaining("Pool double-lease guard triggered"), undefined, undefined);
|
|
});
|
|
|
|
it("creates fresh when pool disabled", async () => {
|
|
const createWorktree = vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" });
|
|
const result = await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: {},
|
|
createWorktree,
|
|
});
|
|
expect(result.source).toBe("fresh");
|
|
expect(createWorktree).toHaveBeenCalled();
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: "/tmp/new", branch: "fusion/fn-1" });
|
|
});
|
|
|
|
it("skips init command when runInitCommand false", async () => {
|
|
const runConfiguredCommand = vi.fn();
|
|
await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { worktreeInitCommand: "pnpm i" } as any,
|
|
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" }),
|
|
runConfiguredCommand,
|
|
runInitCommand: false,
|
|
});
|
|
expect(runConfiguredCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("invokes desktop artifact cleanup before init command for fresh acquisition", async () => {
|
|
const runConfiguredCommand = vi.fn().mockResolvedValue({ exitCode: 0, stderr: "", stdout: "" });
|
|
|
|
await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { worktreeInitCommand: "pnpm install" } as any,
|
|
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" }),
|
|
runConfiguredCommand,
|
|
runInitCommand: true,
|
|
});
|
|
|
|
expect(desktopArtifacts.removeDesktopBuildArtifacts).toHaveBeenCalledWith("/tmp/new", undefined);
|
|
const cleanupOrder = vi.mocked(desktopArtifacts.removeDesktopBuildArtifacts).mock.invocationCallOrder[0];
|
|
const initOrder = runConfiguredCommand.mock.invocationCallOrder[0];
|
|
expect(cleanupOrder).toBeLessThan(initOrder);
|
|
});
|
|
|
|
it("invokes desktop artifact cleanup once for pooled acquisition", async () => {
|
|
const runConfiguredCommand = vi.fn();
|
|
|
|
await acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { recycleWorktrees: true, worktreeInitCommand: "pnpm install" } as any,
|
|
pool: {
|
|
acquire: (_taskId: string) => "/tmp/pooled",
|
|
prepareForTask: vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath: "/tmp/pooled", reclaimed: false }),
|
|
release: vi.fn(),
|
|
} as any,
|
|
createWorktree: vi.fn(),
|
|
runConfiguredCommand,
|
|
runInitCommand: true,
|
|
});
|
|
|
|
expect(desktopArtifacts.removeDesktopBuildArtifacts).toHaveBeenCalledTimes(1);
|
|
expect(desktopArtifacts.removeDesktopBuildArtifacts).toHaveBeenCalledWith("/tmp/pooled", undefined);
|
|
expect(runConfiguredCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("FN-4834: logs worktree init stderr in task log outcome", async () => {
|
|
const runConfiguredCommand = vi.fn().mockResolvedValue({
|
|
exitCode: 1,
|
|
stderr: "ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE Cannot install with \"frozen-lockfile\" because pnpm-lock.yaml is not up to date",
|
|
stdout: "",
|
|
});
|
|
|
|
await expect(acquireTaskWorktree({
|
|
task,
|
|
rootDir: process.cwd(),
|
|
store,
|
|
settings: { worktreeInitCommand: "pnpm install --frozen-lockfile" } as any,
|
|
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" }),
|
|
runConfiguredCommand,
|
|
runInitCommand: true,
|
|
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
})).resolves.toBeTruthy();
|
|
|
|
const failureCall = store.logEntry.mock.calls.find((call: unknown[]) => String(call[1]).startsWith("Worktree init command failed"));
|
|
expect(failureCall).toBeDefined();
|
|
expect(failureCall?.[2]).toContain("ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE");
|
|
});
|
|
});
|
|
|
|
describe("acquireTaskWorktree foreign start-point warning", () => {
|
|
it("emits warning/log for fusion/fn-* start point with foreign-attributed tip and stays silent for main", async () => {
|
|
vi.resetModules();
|
|
const warn = vi.fn();
|
|
const logEntry = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const execMock: any = (_command: string, _opts: any, cb: any) => cb(null, "", "");
|
|
execMock[promisify.custom] = (command: string) => {
|
|
if (command.startsWith("git rev-parse --verify \"fusion/fn-4367^")) {
|
|
return Promise.resolve({ stdout: "deadbeefdeadbeef\n", stderr: "" });
|
|
}
|
|
if (command.startsWith("git log -1 --format=%s%x1f%b")) {
|
|
return Promise.resolve({ stdout: "feat(FN-4367): dep\u001fFusion-Task-Id: FN-4367\n", stderr: "" });
|
|
}
|
|
return Promise.resolve({ stdout: "", stderr: "" });
|
|
};
|
|
|
|
vi.doMock("node:child_process", () => ({ exec: execMock, execFile: execMock }));
|
|
const mod = await import("../worktree-acquisition.js");
|
|
|
|
await mod.acquireTaskWorktree({
|
|
task: { id: "FN-4488", title: "Task", description: "Desc", branch: null, worktree: null, executionStartBranch: "fusion/fn-4367" } as any,
|
|
rootDir: "/tmp/repo",
|
|
store: { updateTask: vi.fn().mockResolvedValue(undefined), logEntry } as any,
|
|
settings: {},
|
|
logger: { log: vi.fn(), warn, error: vi.fn() },
|
|
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/repo/.worktrees/x", branch: "fusion/fn-4488" }),
|
|
});
|
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("worktree acquired with foreign-task start point: fusion/fn-4367"));
|
|
expect(logEntry).toHaveBeenCalledWith("FN-4488", expect.stringContaining("worktree acquired with foreign-task start point: fusion/fn-4367"), undefined, undefined);
|
|
|
|
warn.mockClear();
|
|
logEntry.mockClear();
|
|
|
|
await mod.acquireTaskWorktree({
|
|
task: { id: "FN-4488", title: "Task", description: "Desc", branch: null, worktree: null, executionStartBranch: "main" } as any,
|
|
rootDir: "/tmp/repo",
|
|
store: { updateTask: vi.fn().mockResolvedValue(undefined), logEntry } as any,
|
|
settings: {},
|
|
logger: { log: vi.fn(), warn, error: vi.fn() },
|
|
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/repo/.worktrees/x", branch: "fusion/fn-4488" }),
|
|
});
|
|
|
|
expect(warn).not.toHaveBeenCalled();
|
|
expect(logEntry).not.toHaveBeenCalledWith("FN-4488", expect.stringContaining("foreign-task start point"), undefined, undefined);
|
|
});
|
|
});
|