feat(FN-3216): carry branch context through mission planning and task triag

Implements durable branch context tracking across the mission planning lifecycle (FN-3216): adds branch context persistence to the core store and mission store, propagates branch context through planning subtasks into triaged tasks, and exposes the merge context via dashboard API routes with tests c

Fusion-Task-Id: FN-3216
This commit is contained in:
Fusion
2026-05-09 22:36:00 -07:00
committed by gsxdsm
parent 46fdc48767
commit 2598bed041
8 changed files with 208 additions and 9 deletions

View File

@@ -6584,17 +6584,42 @@ export function unlinkFeatureFromTask(featureId: string, projectId?: string): Pr
}
/** Triage a feature — create a task from the feature and link it */
export function triageFeature(featureId: string, taskTitle?: string, taskDescription?: string, projectId?: string): Promise<MissionFeature> {
export function triageFeature(
featureId: string,
taskTitle?: string,
taskDescription?: string,
projectId?: string,
branchOptions?: {
branchSelection?: {
mode: "project-default" | "auto-new" | "existing" | "custom-new";
branchName?: string;
baseBranch?: string;
};
branchAssignment?: { mode: "shared" | "per-task-derived" };
},
): Promise<MissionFeature> {
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), {
method: "POST",
body: JSON.stringify({ taskTitle, taskDescription }),
body: JSON.stringify({ taskTitle, taskDescription, ...branchOptions }),
});
}
/** Triage all "defined" features in a slice */
export function triageAllSliceFeatures(sliceId: string, projectId?: string): Promise<{ triaged: MissionFeature[]; count: number }> {
export function triageAllSliceFeatures(
sliceId: string,
projectId?: string,
branchOptions?: {
branchSelection?: {
mode: "project-default" | "auto-new" | "existing" | "custom-new";
branchName?: string;
baseBranch?: string;
};
branchAssignment?: { mode: "shared" | "per-task-derived" };
},
): Promise<{ triaged: MissionFeature[]; count: number }> {
return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), {
method: "POST",
body: JSON.stringify(branchOptions ?? {}),
});
}

View File

@@ -1939,6 +1939,12 @@ describe("POST /subtasks/*", () => {
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
branch: "feature/planning",
baseBranch: "main",
branchContext: {
groupId: `planning:${start.body.sessionId}`,
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "main",
},
}));
});
@@ -1974,9 +1980,19 @@ describe("POST /subtasks/*", () => {
expect(createRes.status).toBe(201);
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({
branch: "feature/planning/first-task",
branchContext: expect.objectContaining({
groupId: `planning:${start.body.sessionId}`,
source: "planning",
assignmentMode: "per-task-derived",
}),
}));
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({
branch: "feature/planning/second-task",
branchContext: expect.objectContaining({
groupId: `planning:${start.body.sessionId}`,
source: "planning",
assignmentMode: "per-task-derived",
}),
}));
});

View File

@@ -55,6 +55,7 @@ import {
rateLimited,
} from "./api-error.js";
import type { AiSessionStore } from "./ai-session-store.js";
import { resolveBranchAssignmentContext, resolveBranchSelection } from "./routes/branch-selection.js";
// ── Validation Utilities ────────────────────────────────────────────────────
@@ -2441,7 +2442,7 @@ export function createMissionRouter(
"/features/:featureId/triage",
catchTypedHandler(async (req, res) => {
const { featureId } = req.params;
const { taskTitle, taskDescription } = req.body || {};
const { taskTitle, taskDescription, branch, baseBranch, branchSelection, branchAssignment } = req.body || {};
if (!validateFeatureId(featureId)) {
throw badRequest("Invalid feature ID format");
@@ -2453,10 +2454,18 @@ export function createMissionRouter(
}
try {
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment);
const feature = await missionStore.triageFeature(
featureId,
taskTitle || undefined,
taskDescription || undefined,
{
branch: resolvedBranch,
baseBranch: resolvedBaseBranch,
assignmentMode: branchMode,
},
);
res.json(feature);
} catch (err: unknown) {
@@ -2481,6 +2490,7 @@ export function createMissionRouter(
"/slices/:sliceId/triage-all",
catchTypedHandler(async (req, res) => {
const { sliceId } = req.params;
const { branch, baseBranch, branchSelection, branchAssignment } = req.body || {};
if (!validateSliceId(sliceId)) {
throw badRequest("Invalid slice ID format");
@@ -2492,7 +2502,14 @@ export function createMissionRouter(
}
try {
const triaged = await missionStore.triageSlice(sliceId);
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment);
const triaged = await missionStore.triageSlice(sliceId, {
branch: resolvedBranch,
baseBranch: resolvedBaseBranch,
assignmentMode: branchMode,
});
res.json({ triaged, count: triaged.length });
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);

View File

@@ -210,6 +210,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment);
const planningBranchContext = {
groupId: `planning:${sessionId}`,
source: "planning" as const,
assignmentMode: branchMode,
inheritedBaseBranch: resolvedBaseBranch,
};
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>();
@@ -236,6 +242,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
source: { sourceType: "api", sourceParentTaskId: typeof parentTaskId === "string" ? parentTaskId : undefined },
branch: taskBranch,
baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext,
});
tempIdToTaskId.set(item.tempId, task.id);
@@ -1206,6 +1213,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment);
const planningBranchContext = {
groupId: `planning:${planningSessionId}`,
source: "planning" as const,
assignmentMode: branchMode,
inheritedBaseBranch: resolvedBaseBranch,
};
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>();
@@ -1225,6 +1238,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
source: { sourceType: "api", sourceMetadata: { planningSessionId } },
branch: taskBranch,
baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext,
});
tempIdToTaskId.set(item.id, task.id);