FN-5671: add branch-strategy selection to new task creation

Add branch-strategy based task branch handling from modal through task creation routes.

- add branch strategy dropdown + validation in New Task modal and TaskForm, including required branch-name handling for existing/custom-new modes
- send structured branchSelection payload from dashboard API client and cover UI/API behavior with new tests
- add server-side branch selection mode parsing, auto-new branch derivation (fusion/<task-id>-<slug>), and response updates when auto branch is generated
- update engine/worktree and dashboard route tests plus dashboard guide documentation
- add a changeset for @runfusion/fusion patch release

Files changed:
 .changeset/fn-5671-branch-strategy-dropdown.md     |  7 ++
 docs/dashboard-guide.md                            | 16 +++++
 packages/dashboard/app/__tests__/api-tasks.test.ts | 21 ++++++
 packages/dashboard/app/api/legacy.ts               |  9 +++
 packages/dashboard/app/components/NewTaskModal.css |  8 +++
 packages/dashboard/app/components/NewTaskModal.tsx | 41 +++++++++---
 packages/dashboard/app/components/TaskForm.tsx     | 30 ++++++++-
 .../app/components/__tests__/NewTaskModal.test.tsx | 77 +++++++++++++++++++---
 .../src/__tests__/branch-selection.test.ts         | 13 ++++
 .../dashboard/src/__tests__/routes-tasks.test.ts   | 72 ++++++++++++++++++++
 packages/dashboard/src/routes/branch-selection.ts  | 45 ++++++++-----
 .../src/routes/register-task-workflow-routes.ts    | 22 +++++--
 .../engine/src/__tests__/worktree-pool.test.ts     | 41 ++++++++++++
 packages/engine/src/worktree-pool.ts               |  2 +-
 14 files changed, 361 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-5671
Fusion-Task-Lineage: 7f2e7b68-050d-4a2c-af0b-cb895de35576
This commit is contained in:
gsxdsm
2026-05-29 09:44:06 -07:00
parent 16d20063bf
commit 4fee2c1324
14 changed files with 376 additions and 58 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
Add a branch-strategy dropdown to the New Task dialog with project-default, auto-new, existing, and custom-new modes.
New tasks now submit `branchSelection`, and `auto-new` derives a persisted branch name using `fusion/{task-id}-{short-name}`.

View File

@@ -111,6 +111,22 @@ Planning Mode now includes branch controls on the summary screen before you crea
These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows.
## New Task Modal Branch Strategy
The **New Task** dialog uses the same four-option **Branch strategy** selector and `branchSelection` payload as Planning Mode:
- `Use project/default branch`
- `Create auto-named branch per task`
- `Use existing branch`
- `Create custom new branch`
Rules:
- `existing` and `custom-new` require a branch name.
- `project-default` leaves `branch` unset.
- `auto-new` creates a branch after task creation using `fusion/{task-id}-{short-name}` (for example `fusion/fn-5671-branch-strategy-dropdown`).
- `Merge target / base branch` stays optional for all modes.
## Chat View
Chat view provides project-scoped conversations with agents.

View File

@@ -653,6 +653,27 @@ describe("createTask", () => {
expect(body).not.toHaveProperty("baseBranch");
});
it("serializes branchSelection in create payload when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_CREATED_TASK));
await createTask({
description: "Task with branch strategy",
branchSelection: {
mode: "custom-new",
branchName: "feature/new-task-flow",
baseBranch: "main",
},
});
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.branchSelection).toEqual({
mode: "custom-new",
branchName: "feature/new-task-flow",
baseBranch: "main",
});
});
it("serializes nodeId in create payload when execution target is specified", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
...FAKE_CREATED_TASK,

View File

@@ -330,7 +330,14 @@ export interface CreateTaskRequestOptions {
localNodeId?: string;
}
export type BranchSelectionInput = {
mode: "project-default" | "auto-new" | "existing" | "custom-new";
branchName?: string;
baseBranch?: string;
};
export type CreateTaskInput = TaskCreateInput & {
branchSelection?: BranchSelectionInput;
acknowledgedDuplicates?: string[];
bypassDuplicateCheck?: boolean;
};
@@ -375,6 +382,7 @@ export async function createTask(
nodeId,
branch,
baseBranch,
branchSelection,
githubTracking,
acknowledgedDuplicates,
bypassDuplicateCheck,
@@ -409,6 +417,7 @@ export async function createTask(
nodeId,
branch,
baseBranch,
branchSelection,
githubTracking,
acknowledgedDuplicates,
bypassDuplicateCheck,

View File

@@ -179,6 +179,10 @@
line-height: 1.4;
}
.new-task-branch-error {
margin: 0 var(--space-xl) var(--space-sm);
}
.new-task-modal textarea {
min-height: calc(var(--space-xl) * 3 + var(--space-md));
transition: height var(--transition-instant);
@@ -493,6 +497,10 @@
padding: 0 var(--space-md);
}
.new-task-branch-error {
margin: 0 var(--space-md) var(--space-sm);
}
.new-task-quick-fields .dep-trigger {
width: 100%;
min-height: 36px;

View File

@@ -7,7 +7,7 @@ import { uploadAttachment } from "../api";
import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { TaskForm, type PendingImage } from "./TaskForm";
import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm";
import { REPO_OVERRIDE_RE } from "./githubTracking";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
@@ -41,6 +41,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
: {};
const [description, setDescription] = useState("");
const [dependencies, setDependencies] = useState<string[]>([]);
const [branchMode, setBranchMode] = useState<BranchSelectionMode>("project-default");
const [branch, setBranch] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
@@ -144,6 +145,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const githubRepoOverrideTrimmed = githubRepoOverride.trim();
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed);
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new";
const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim();
// Track dirty state
useEffect(() => {
@@ -160,12 +163,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
reviewLevel !== undefined ||
priority !== DEFAULT_TASK_PRIORITY ||
nodeId !== undefined ||
branchMode !== "project-default" ||
branch !== "" ||
baseBranch !== "" ||
githubTrackingEnabled ||
githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
const handleClose = useCallback(async () => {
if (hasDirtyState) {
@@ -195,6 +199,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setReviewLevel(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setBranchMode("project-default");
setBranch("");
setBaseBranch("");
setHasDirtyState(false);
@@ -205,7 +210,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const handleSubmit = useCallback(async () => {
const trimmedDesc = description.trim();
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid) return;
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return;
setIsSubmitting(true);
try {
@@ -213,7 +218,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const validatorSlashIdx = validatorModel.indexOf("/");
const planningSlashIdx = planningModel.indexOf("/");
const task = await onCreateTask({
const createInput: TaskCreateInput & {
branchSelection?: {
mode: BranchSelectionMode;
branchName?: string;
baseBranch?: string;
};
} = {
title: undefined,
description: trimmedDesc,
column: "triage",
@@ -233,8 +244,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
reviewLevel,
priority,
nodeId,
branch: branch.trim() === "" ? undefined : branch.trim(),
baseBranch: baseBranch.trim() === "" ? undefined : baseBranch.trim(),
branchSelection: {
mode: branchMode,
...(isBranchNameRequired && branch.trim() ? { branchName: branch.trim() } : {}),
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
},
...(githubTrackingEnabled || githubRepoOverrideTrimmed !== ""
? {
githubTracking: {
@@ -243,7 +257,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
},
}
: {}),
});
};
const task = await onCreateTask(createInput);
// Upload pending images as attachments
if (pendingImages.length > 0) {
@@ -278,6 +294,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setReviewLevel(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setBranchMode("project-default");
setBranch("");
setBaseBranch("");
@@ -288,7 +305,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -492,6 +509,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPriorityChange={setPriority}
branch={branch}
onBranchChange={setBranch}
branchMode={branchMode}
onBranchModeChange={setBranchMode}
baseBranch={baseBranch}
onBaseBranchChange={setBaseBranch}
nodeId={nodeId}
@@ -508,6 +527,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
</div>
{hasInvalidBranchSelection && (
<div className="form-error new-task-branch-error">Branch name is required for this branch strategy.</div>
)}
<div className="modal-actions">
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
Cancel
@@ -515,7 +538,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
<button
className="btn btn-primary btn-sm"
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid}
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
>
{isSubmitting ? "Creating..." : "Create Task"}
</button>

View File

@@ -36,6 +36,7 @@ export interface PendingImage {
}
type TaskExecutionModeSelection = "standard" | "fast";
export type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new";
export interface TaskFormProps {
mode: "create" | "edit";
@@ -51,6 +52,8 @@ export interface TaskFormProps {
onDependenciesChange: (deps: string[]) => void;
branch?: string;
onBranchChange?: (value: string) => void;
branchMode?: BranchSelectionMode;
onBranchModeChange?: (value: BranchSelectionMode) => void;
baseBranch?: string;
onBaseBranchChange?: (value: string) => void;
nodeId?: string;
@@ -130,6 +133,8 @@ export function TaskForm({
onDependenciesChange,
branch,
onBranchChange,
branchMode,
onBranchModeChange,
baseBranch,
onBaseBranchChange,
nodeId,
@@ -967,12 +972,31 @@ export function TaskForm({
</>
)}
{(onBranchChange || onBaseBranchChange) && (
{(onBranchChange || onBaseBranchChange || onBranchModeChange) && (
<div className="form-group">
<label>Branch Settings</label>
{onBranchChange && (
{onBranchModeChange ? (
<>
<label htmlFor="task-working-branch" className="model-select-label">Working branch</label>
<label htmlFor="task-branch-mode" className="model-select-label">Branch strategy</label>
<select
id="task-branch-mode"
className="input"
value={branchMode ?? "project-default"}
onChange={(event) => onBranchModeChange(event.target.value as BranchSelectionMode)}
disabled={disabled}
>
<option value="project-default">Use project/default branch</option>
<option value="auto-new">Create auto-named branch per task</option>
<option value="existing">Use existing branch</option>
<option value="custom-new">Create custom new branch</option>
</select>
</>
) : null}
{onBranchChange && (!onBranchModeChange || branchMode === "existing" || branchMode === "custom-new") && (
<>
<label htmlFor="task-working-branch" className="model-select-label">
{onBranchModeChange ? "Branch name" : "Working branch"}
</label>
<input
id="task-working-branch"
className="input"

View File

@@ -215,27 +215,7 @@ describe("NewTaskModal", () => {
});
});
it("includes branch and baseBranch when provided", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: " feature/fn-3422 " } });
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
branch: "feature/fn-3422",
baseBranch: "main",
}),
);
});
});
it("omits branch and baseBranch when left blank", async () => {
it("submits project-default branch selection by default", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task without branches" } });
@@ -244,8 +224,89 @@ describe("NewTaskModal", () => {
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
branch: undefined,
baseBranch: undefined,
branchSelection: { mode: "project-default" },
}),
);
});
});
it("submits existing branch selection with trimmed names", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "existing" } });
fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/fn-3422 " } });
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
branchSelection: {
mode: "existing",
branchName: "feature/fn-3422",
baseBranch: "main",
},
}),
);
});
});
it("submits auto-new branch selection", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with auto new" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "auto-new" } });
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
branchSelection: {
mode: "auto-new",
baseBranch: "main",
},
}),
);
});
});
it("requires branch name for custom-new mode", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled();
expect(screen.getByText("Branch name is required for this branch strategy.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).not.toHaveBeenCalled();
});
});
it("submits custom-new branch selection when branch name exists", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with custom new" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/custom " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
branchSelection: {
mode: "custom-new",
branchName: "feature/custom",
},
}),
);
});

View File

@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
deriveAutoTaskBranch,
derivePerTaskBranch,
getBranchSelectionMode,
resolveBranchAssignmentContext,
resolveBranchSelection,
} from "../routes/branch-selection.js";
@@ -33,4 +35,15 @@ describe("branch-selection", () => {
expect(derivePerTaskBranch("feature/planning", "FN-123 add parser")).toBe("feature/planning/fn-123-add-parser");
expect(derivePerTaskBranch(undefined, "FN-123")).toBeUndefined();
});
it("derives auto task branches from id + short name", () => {
expect(deriveAutoTaskBranch("FN-5671", "Branch Strategy Dropdown")).toBe("fusion/fn-5671-branch-strategy-dropdown");
expect(deriveAutoTaskBranch("FN-5671", " ")).toBe("fusion/fn-5671");
expect(deriveAutoTaskBranch("FN-5671", "!!!")).toBe("fusion/fn-5671");
});
it("reads requested branch mode", () => {
expect(getBranchSelectionMode(undefined)).toBeUndefined();
expect(getBranchSelectionMode({ mode: "auto-new" })).toBe("auto-new");
});
});

View File

@@ -867,6 +867,78 @@ describe("POST /tasks", () => {
);
});
it("applies branchSelection on create when supplied", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
column: "triage",
branch: "feature/existing",
baseBranch: "main",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Task with branch selection",
branchSelection: {
mode: "existing",
branchName: " feature/existing ",
baseBranch: " main ",
},
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
branch: "feature/existing",
baseBranch: "main",
}),
expect.any(Object),
);
});
it("persists an auto-derived branch for auto-new branchSelection", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-5671",
title: "Branch Strategy Dropdown",
description: "Task with auto-new strategy",
column: "triage",
branch: undefined,
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-5671",
title: "Branch Strategy Dropdown",
description: "Task with auto-new strategy",
column: "triage",
branch: "fusion/fn-5671-branch-strategy-dropdown",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Task with auto-new strategy",
title: "Branch Strategy Dropdown",
branchSelection: {
mode: "auto-new",
},
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.updateTask).toHaveBeenCalledWith("FN-5671", {
branch: "fusion/fn-5671-branch-strategy-dropdown",
});
expect(res.body.branch).toBe("fusion/fn-5671-branch-strategy-dropdown");
});
it("returns 400 when create branch payload is not a string", async () => {
const res = await REQUEST(
buildApp(),

View File

@@ -12,6 +12,27 @@ export interface BranchSelectionPayload {
baseBranch?: unknown;
}
export function getBranchSelectionMode(selectionInput: unknown): BranchSelectionMode | undefined {
if (selectionInput === undefined || selectionInput === null) return undefined;
if (typeof selectionInput !== "object" || Array.isArray(selectionInput)) {
throw badRequest("branchSelection must be an object");
}
const selection = selectionInput as BranchSelectionPayload;
const mode = typeof selection.mode === "string" ? selection.mode : undefined;
if (!mode) {
throw badRequest("branchSelection.mode is required");
}
if (![
"project-default",
"auto-new",
"existing",
"custom-new",
].includes(mode)) {
throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new");
}
return mode as BranchSelectionMode;
}
export type PlanningBranchMode = "shared" | "per-task-derived";
export interface ResolvedBranchSelection {
@@ -50,24 +71,8 @@ export function resolveBranchSelection(
return fallback;
}
if (typeof selectionInput !== "object" || Array.isArray(selectionInput)) {
throw badRequest("branchSelection must be an object");
}
const mode = getBranchSelectionMode(selectionInput);
const selection = selectionInput as BranchSelectionPayload;
const mode = typeof selection.mode === "string" ? selection.mode : undefined;
if (!mode) {
throw badRequest("branchSelection.mode is required");
}
if (![
"project-default",
"auto-new",
"existing",
"custom-new",
].includes(mode)) {
throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new");
}
const baseBranch = normalizeOptionalBranch(selection.baseBranch, "branchSelection.baseBranch");
@@ -118,6 +123,12 @@ function sanitizeSegment(input: string): string {
.slice(0, 48);
}
export function deriveAutoTaskBranch(taskId: string, shortName: string): string {
const base = `fusion/${taskId.toLowerCase()}`;
const segment = sanitizeSegment(shortName ?? "");
return segment ? `${base}-${segment}` : base;
}
export function derivePerTaskBranch(sharedBranch: string | undefined, taskSegment: string): string | undefined {
const base = normalizeOptionalBranch(sharedBranch, "sharedBranch");
if (!base) return undefined;

View File

@@ -43,7 +43,7 @@ import { planTaskWorktreePath } from "@fusion/engine";
import type { RunAuditEventInput } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
import { resolveBranchSelection } from "./branch-selection.js";
import { deriveAutoTaskBranch, getBranchSelectionMode, resolveBranchSelection } from "./branch-selection.js";
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
@@ -940,6 +940,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
? source
: { sourceType: "api" as const };
const requestedBranchMode = getBranchSelectionMode(branchSelection);
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
@@ -1222,8 +1223,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
);
// Newly created tasks are still triage/todo and cannot be worktree-acquired until
// moved to in-progress, so this in-request branch update is safe.
const taskWithAutoBranch = requestedBranchMode === "auto-new"
? await scopedStore.updateTask(task.id, {
branch: deriveAutoTaskBranch(
task.id,
(((task.title ?? "").trim() || task.description).slice(0, 60)),
),
})
: task;
const deterministicReconcile = await reconcileDeterministicDuplicate(scopedStore, {
createdTask: task,
createdTask: taskWithAutoBranch,
fingerprint: bypassDuplicateCheck === true ? null : contentFingerprint,
windowMs: 60_000,
logger: runtimeLogger,
@@ -1237,8 +1249,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
try {
await scopedStore.recordActivity({
type: "task:duplicate-warning-overridden",
taskId: task.id,
taskTitle: task.title,
taskId: taskWithAutoBranch.id,
taskTitle: taskWithAutoBranch.title,
details: `Created despite ${acknowledgedDuplicateIds.length} possible duplicate(s): ${acknowledgedDuplicateIds.join(", ")}`,
metadata: {
acknowledgedDuplicateIds,
@@ -1253,7 +1265,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
}
res.status(201).json(task);
res.status(201).json(taskWithAutoBranch);
return;
} finally {
deterministicGuard.releaseLock();

View File

@@ -363,6 +363,46 @@ describe("WorktreePool", () => {
});
});
it("maps slugged fusion branches to canonical task IDs", async () => {
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git checkout -B "fusion/fn-5671-add-dropdown" main') {
const err: any = new Error("branch conflict");
err.stderr = Buffer.from("fatal: 'fusion/fn-5671-add-dropdown' 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-5671-add-dropdown", ""].join("\n"));
}
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-5671-add-dropdown^{commit}'")) {
return Buffer.from("abc123def456\n");
}
return Buffer.from("");
});
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "live-foreign",
livePath: "/other/wt",
error: new BranchConflictError({
branchName: "fusion/fn-5671-add-dropdown",
conflictingWorktreePath: "/other/wt",
existingTipSha: "abc123def456",
strandedCommits: [],
startPoint: "main",
recommendedAction: "Inspect/reclaim.",
}),
});
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-5671-add-dropdown")).rejects.toBeInstanceOf(BranchConflictError);
expect(inspectSpy).toHaveBeenCalledWith(expect.objectContaining({
branchName: "fusion/fn-5671-add-dropdown",
ownerTaskId: "FN-5671",
requestingTaskId: "FN-5671",
}));
});
it("throws BranchConflictError for cross-task live-foreign conflicts", async () => {
mockedExistsSync.mockReturnValue(true);
@@ -897,6 +937,7 @@ describe("cleanupOrphanedWorktrees", () => {
expect(removeCalls[0][0]).not.toContain("active-wt");
});
it("handles git worktree remove failures gracefully (non-fatal)", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("fail-wt"),

View File

@@ -252,7 +252,7 @@ export function isInsideWorktreesDir(
* up via {@link cleanupOrphanedWorktrees}.
*/
function deriveTaskIdFromBranch(branchName: string): string {
const match = branchName.match(/^fusion\/(fn-\d+)(?:-\d+)?$/i);
const match = branchName.match(/^fusion\/(fn-\d+)(?:-\d+)?(?:-[a-z0-9._-]+)*$/i);
return match ? match[1].toUpperCase() : branchName.toUpperCase();
}