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

@@ -609,6 +609,38 @@ describe("TaskStore", () => {
expect(clearedBaseBranch.title).toBe("Keep this title"); expect(clearedBaseBranch.title).toBe("Keep this title");
}); });
it("persists planning branch context metadata on create", async () => {
const task = await store.createTask({
description: "Planning branch context",
baseBranch: "release/2026.10",
branch: "planning/session-42",
branchContext: {
groupId: "planning-session-42",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "release/2026.10",
},
});
expect(task.branchContext).toEqual({
groupId: "planning-session-42",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "release/2026.10",
});
const detail = await store.getTask(task.id);
expect(detail.branchContext).toEqual(task.branchContext);
expect(detail.sourceMetadata).toMatchObject({
fusionBranchContext: {
groupId: "planning-session-42",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "release/2026.10",
},
});
});
it("round-trips branch fields through listTasks and reload", async () => { it("round-trips branch fields through listTasks and reload", async () => {
store.close(); store.close();
store = new TaskStore(rootDir, globalDir); store = new TaskStore(rootDir, globalDir);

View File

@@ -3040,6 +3040,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
featureId: string, featureId: string,
taskTitle?: string, taskTitle?: string,
taskDescription?: string, taskDescription?: string,
branchOptions?: {
branch?: string;
baseBranch?: string;
assignmentMode?: "shared" | "per-task-derived";
},
): Promise<MissionFeature> { ): Promise<MissionFeature> {
if (!this.taskStore) { if (!this.taskStore) {
throw new Error("TaskStore reference is required for triage operations"); throw new Error("TaskStore reference is required for triage operations");
@@ -3065,10 +3070,26 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description = enriched || feature.title; description = enriched || feature.title;
} }
const slice = this.getSlice(feature.sliceId);
const milestone = slice ? this.getMilestone(slice.milestoneId) : undefined;
const missionId = milestone?.missionId;
// Create the task // Create the task
const task = await this.taskStore.createTask({ const task = await this.taskStore.createTask({
title: taskTitle || feature.title, title: taskTitle || feature.title,
description, description,
branch: branchOptions?.branch,
baseBranch: branchOptions?.baseBranch,
...(missionId
? {
branchContext: {
groupId: `mission:${missionId}`,
source: "mission" as const,
assignmentMode: branchOptions?.assignmentMode ?? "shared",
inheritedBaseBranch: branchOptions?.baseBranch,
},
}
: {}),
}); });
// Link the feature to the new task (this also updates feature status to "triaged") // Link the feature to the new task (this also updates feature status to "triaged")
@@ -3088,7 +3109,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* @returns Array of updated features that were triaged * @returns Array of updated features that were triaged
* @throws Error if slice not found or TaskStore not available * @throws Error if slice not found or TaskStore not available
*/ */
async triageSlice(sliceId: string): Promise<MissionFeature[]> { async triageSlice(
sliceId: string,
branchOptions?: {
branch?: string;
baseBranch?: string;
assignmentMode?: "shared" | "per-task-derived";
},
): Promise<MissionFeature[]> {
if (!this.taskStore) { if (!this.taskStore) {
throw new Error("TaskStore reference is required for triage operations"); throw new Error("TaskStore reference is required for triage operations");
} }
@@ -3103,7 +3131,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const triaged: MissionFeature[] = []; const triaged: MissionFeature[] = [];
for (const feature of definedFeatures) { for (const feature of definedFeatures) {
const updated = await this.triageFeature(feature.id); const branch = branchOptions?.assignmentMode === "per-task-derived"
? (branchOptions?.branch ? `${branchOptions.branch}/${feature.id.toLowerCase()}` : undefined)
: branchOptions?.branch;
const updated = await this.triageFeature(feature.id, undefined, undefined, {
...branchOptions,
branch,
});
triaged.push(updated); triaged.push(updated);
} }

View File

@@ -129,6 +129,42 @@ interface TaskRow {
} }
/** Database row shape for the task_documents table. */ /** Database row shape for the task_documents table. */
const TASK_BRANCH_CONTEXT_METADATA_KEY = "fusionBranchContext";
function parseTaskBranchContextFromSourceMetadata(sourceMetadata: Record<string, unknown> | undefined): import("./types.js").TaskBranchContext | undefined {
const raw = sourceMetadata?.[TASK_BRANCH_CONTEXT_METADATA_KEY];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const candidate = raw as Record<string, unknown>;
if (typeof candidate.groupId !== "string" || !candidate.groupId.trim()) return undefined;
if (candidate.source !== "planning" && candidate.source !== "mission") return undefined;
if (candidate.assignmentMode !== "shared" && candidate.assignmentMode !== "per-task-derived") return undefined;
const inheritedBaseBranch = typeof candidate.inheritedBaseBranch === "string" && candidate.inheritedBaseBranch.trim().length > 0
? candidate.inheritedBaseBranch.trim()
: undefined;
return {
groupId: candidate.groupId,
source: candidate.source,
assignmentMode: candidate.assignmentMode,
inheritedBaseBranch,
};
}
function withTaskBranchContextInSourceMetadata(
sourceMetadata: Record<string, unknown> | undefined,
branchContext: import("./types.js").TaskBranchContext | undefined,
): Record<string, unknown> | undefined {
if (!branchContext) return sourceMetadata;
return {
...(sourceMetadata ?? {}),
[TASK_BRANCH_CONTEXT_METADATA_KEY]: {
groupId: branchContext.groupId,
source: branchContext.source,
assignmentMode: branchContext.assignmentMode,
...(branchContext.inheritedBaseBranch ? { inheritedBaseBranch: branchContext.inheritedBaseBranch } : {}),
},
};
}
interface TaskDocumentRow { interface TaskDocumentRow {
id: string; id: string;
taskId: string; taskId: string;
@@ -846,7 +882,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceSessionId: row.sourceSessionId || undefined, sourceSessionId: row.sourceSessionId || undefined,
sourceMessageId: row.sourceMessageId || undefined, sourceMessageId: row.sourceMessageId || undefined,
sourceParentTaskId: row.sourceParentTaskId || undefined, sourceParentTaskId: row.sourceParentTaskId || undefined,
sourceMetadata: fromJson<Record<string, unknown>>(row.sourceMetadata) ?? undefined, sourceMetadata: (() => {
const parsed = fromJson<Record<string, unknown>>(row.sourceMetadata) ?? undefined;
return withTaskBranchContextInSourceMetadata(parsed, parseTaskBranchContextFromSourceMetadata(parsed));
})(),
branchContext: (() => {
const parsed = fromJson<Record<string, unknown>>(row.sourceMetadata) ?? undefined;
return parseTaskBranchContextFromSourceMetadata(parsed);
})(),
checkedOutBy: row.checkedOutBy || undefined, checkedOutBy: row.checkedOutBy || undefined,
checkedOutAt: row.checkedOutAt || undefined, checkedOutAt: row.checkedOutAt || undefined,
checkoutNodeId: row.checkoutNodeId || undefined, checkoutNodeId: row.checkoutNodeId || undefined,
@@ -2485,7 +2528,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceSessionId: input.source?.sourceSessionId, sourceSessionId: input.source?.sourceSessionId,
sourceMessageId: input.source?.sourceMessageId, sourceMessageId: input.source?.sourceMessageId,
sourceParentTaskId: input.source?.sourceParentTaskId, sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata, sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext),
branchContext: input.branchContext,
column: input.column || "triage", column: input.column || "triage",
dependencies: input.dependencies || [], dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined, breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,

View File

@@ -1066,6 +1066,17 @@ export interface TaskSource {
sourceMetadata?: Record<string, unknown>; sourceMetadata?: Record<string, unknown>;
} }
export type TaskBranchGroupSource = "planning" | "mission";
export type TaskBranchAssignmentMode = "shared" | "per-task-derived";
export interface TaskBranchContext {
groupId: string;
source: TaskBranchGroupSource;
assignmentMode: TaskBranchAssignmentMode;
inheritedBaseBranch?: string;
}
export interface Task { export interface Task {
id: string; id: string;
title?: string; title?: string;
@@ -1099,6 +1110,8 @@ export interface Task {
* the conventional `fn/{task-id}` when conflict recovery generated a * the conventional `fn/{task-id}` when conflict recovery generated a
* unique suffixed name (e.g., `fn/fn-042-2`). */ * unique suffixed name (e.g., `fn/fn-042-2`). */
branch?: string; branch?: string;
/** Optional planning/mission branch-group metadata carried across related tasks. */
branchContext?: TaskBranchContext;
/** Internal execution-only provenance for dependency-start handoff. /** Internal execution-only provenance for dependency-start handoff.
* When set, the scheduler asked executor to start from an upstream dependency * When set, the scheduler asked executor to start from an upstream dependency
* branch. This is transient execution state and should be cleared after use. */ * branch. This is transient execution state and should be cleared after use. */
@@ -1302,6 +1315,8 @@ export interface TaskCreateInput {
baseBranch?: string; baseBranch?: string;
/** Actual git working branch name used for this task's worktree. */ /** Actual git working branch name used for this task's worktree. */
branch?: string; branch?: string;
/** Optional planning/mission branch-group metadata carried across related tasks. */
branchContext?: TaskBranchContext;
/** Durable source provenance for the originating external issue. */ /** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue; sourceIssue?: TaskSourceIssue;
/** Optional persisted aggregate token usage snapshot for task creation/import paths. */ /** Optional persisted aggregate token usage snapshot for task creation/import paths. */
@@ -2628,6 +2643,8 @@ export interface ArchivedTaskEntry {
baseBranch?: string; baseBranch?: string;
/** Actual git branch name used for this task's worktree */ /** Actual git branch name used for this task's worktree */
branch?: string; branch?: string;
/** Optional planning/mission branch-group metadata carried across related tasks. */
branchContext?: TaskBranchContext;
/** Base commit SHA for the task's worktree */ /** Base commit SHA for the task's worktree */
baseCommitSha?: string; baseCommitSha?: string;
/** List of files modified by this task */ /** List of files modified by this task */

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 */ /** 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), { return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), {
method: "POST", method: "POST",
body: JSON.stringify({ taskTitle, taskDescription }), body: JSON.stringify({ taskTitle, taskDescription, ...branchOptions }),
}); });
} }
/** Triage all "defined" features in a slice */ /** 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), { return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), {
method: "POST", method: "POST",
body: JSON.stringify(branchOptions ?? {}),
}); });
} }

View File

@@ -1939,6 +1939,12 @@ describe("POST /subtasks/*", () => {
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
branch: "feature/planning", branch: "feature/planning",
baseBranch: "main", 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(createRes.status).toBe(201);
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({
branch: "feature/planning/first-task", 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({ expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({
branch: "feature/planning/second-task", 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, rateLimited,
} from "./api-error.js"; } from "./api-error.js";
import type { AiSessionStore } from "./ai-session-store.js"; import type { AiSessionStore } from "./ai-session-store.js";
import { resolveBranchAssignmentContext, resolveBranchSelection } from "./routes/branch-selection.js";
// ── Validation Utilities ──────────────────────────────────────────────────── // ── Validation Utilities ────────────────────────────────────────────────────
@@ -2441,7 +2442,7 @@ export function createMissionRouter(
"/features/:featureId/triage", "/features/:featureId/triage",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { featureId } = req.params; const { featureId } = req.params;
const { taskTitle, taskDescription } = req.body || {}; const { taskTitle, taskDescription, branch, baseBranch, branchSelection, branchAssignment } = req.body || {};
if (!validateFeatureId(featureId)) { if (!validateFeatureId(featureId)) {
throw badRequest("Invalid feature ID format"); throw badRequest("Invalid feature ID format");
@@ -2453,10 +2454,18 @@ export function createMissionRouter(
} }
try { try {
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment);
const feature = await missionStore.triageFeature( const feature = await missionStore.triageFeature(
featureId, featureId,
taskTitle || undefined, taskTitle || undefined,
taskDescription || undefined, taskDescription || undefined,
{
branch: resolvedBranch,
baseBranch: resolvedBaseBranch,
assignmentMode: branchMode,
},
); );
res.json(feature); res.json(feature);
} catch (err: unknown) { } catch (err: unknown) {
@@ -2481,6 +2490,7 @@ export function createMissionRouter(
"/slices/:sliceId/triage-all", "/slices/:sliceId/triage-all",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { sliceId } = req.params; const { sliceId } = req.params;
const { branch, baseBranch, branchSelection, branchAssignment } = req.body || {};
if (!validateSliceId(sliceId)) { if (!validateSliceId(sliceId)) {
throw badRequest("Invalid slice ID format"); throw badRequest("Invalid slice ID format");
@@ -2492,7 +2502,14 @@ export function createMissionRouter(
} }
try { 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 }); res.json({ triaged, count: triaged.length });
} catch (err: unknown) { } catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err); 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 } = const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch); resolveBranchSelection(branchSelection, branch, baseBranch);
const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); 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 createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>(); 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 }, source: { sourceType: "api", sourceParentTaskId: typeof parentTaskId === "string" ? parentTaskId : undefined },
branch: taskBranch, branch: taskBranch,
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext,
}); });
tempIdToTaskId.set(item.tempId, task.id); tempIdToTaskId.set(item.tempId, task.id);
@@ -1206,6 +1213,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch); resolveBranchSelection(branchSelection, branch, baseBranch);
const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); 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 createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>(); const tempIdToTaskId = new Map<string, string>();
@@ -1225,6 +1238,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
source: { sourceType: "api", sourceMetadata: { planningSessionId } }, source: { sourceType: "api", sourceMetadata: { planningSessionId } },
branch: taskBranch, branch: taskBranch,
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext,
}); });
tempIdToTaskId.set(item.id, task.id); tempIdToTaskId.set(item.id, task.id);