From 795f5d41eca337f2754b1aa735cd6fb8eb045aeb Mon Sep 17 00:00:00 2001 From: fusion-merge-train Date: Mon, 6 Jul 2026 11:42:40 +0200 Subject: [PATCH] feat(missions): per-mission taskPrefix override for triaged task ids Add an optional `taskPrefix` field on Mission so a single mission's tickets can use a distinct id prefix (e.g. ERR-) while the rest of the board keeps the project-wide prefix (default FN-). Previously taskPrefix was project-scoped only; flipping it changed every new ticket board-wide including autopilot. The distributed id allocator was already multi-prefix, so this only threads a per-mission prefix through triage: - Mission.taskPrefix (nullable) persisted via a new additive migration (v140). - MissionStore.triageFeature passes it into TaskCreateInput.taskPrefix (a transient minting hint, not persisted on the task). - createTaskWithDistributedReservation prefers input.taskPrefix over settings. - buildCommitMsgTrailerHook derives the strip PREFIX from the task id itself rather than the project-wide option, so ERR-5 strips correctly while the project prefix is still FN (fixes all engine call sites at once). - Mission create + edit UI (MissionManager) exposes a Task prefix input; mission-routes POST/PATCH validate it (letter-led alphanumeric, uppercased). Co-Authored-By: Claude Opus 4.8 --- .../core/src/__tests__/mission-store.test.ts | 32 +++++++++++++++++ packages/core/src/db.ts | 11 +++++- packages/core/src/mission-store.ts | 11 ++++-- packages/core/src/mission-types.ts | 4 +++ packages/core/src/store.ts | 4 ++- packages/core/src/types.ts | 3 ++ packages/dashboard/app/api/legacy.ts | 4 ++- .../app/components/MissionManager.tsx | 35 +++++++++++++++++++ .../dashboard/app/components/mission-types.ts | 1 + packages/dashboard/src/mission-routes.ts | 21 +++++++++-- .../src/__tests__/worktree-hooks.test.ts | 9 +++++ packages/engine/src/worktree-hooks.ts | 11 +++++- 12 files changed, 138 insertions(+), 8 deletions(-) diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 0c4cdfd823..caa9023a8f 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -192,6 +192,24 @@ describe("MissionStore", () => { expect(store.getMission(mission.id)?.branchStrategy).toBeUndefined(); }); + it("round-trips mission taskPrefix on create", () => { + const mission = store.createMission({ title: "Prefixed", taskPrefix: "ERR" }); + expect(store.getMission(mission.id)?.taskPrefix).toBe("ERR"); + }); + + it("updates mission taskPrefix", () => { + const mission = store.createMission({ title: "Original" }); + const updated = store.updateMission(mission.id, { taskPrefix: "BUG" }); + expect(updated.taskPrefix).toBe("BUG"); + expect(store.getMission(mission.id)?.taskPrefix).toBe("BUG"); + }); + + it("reads undefined taskPrefix for legacy rows", () => { + const mission = store.createMission({ title: "Legacy row" }); + db.prepare("UPDATE missions SET taskPrefix = NULL WHERE id = ?").run(mission.id); + expect(store.getMission(mission.id)?.taskPrefix).toBeUndefined(); + }); + // FNXC:CoreTests 2026-06-25-21:50: real-sleep removed (FN-5048); advance the // fake clock between create and update so updatedAt > createdAt deterministically. it("updates a mission", () => { @@ -2351,6 +2369,20 @@ describe("MissionStore", () => { expect(task!.missionId).toBe(mission.id); }); + it("mints the triaged task id with the mission's taskPrefix", async () => { + const { TaskStore } = await import("../store.js"); + const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true }); + const msWithTs = ts.getMissionStore(); + + const mission = msWithTs.createMission({ title: "Mission", taskPrefix: "ERR" }); + const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" }); + const slice = msWithTs.addSlice(milestone.id, { title: "Slice" }); + const feature = msWithTs.addFeature(slice.id, { title: "Feature" }); + + const triaged = await msWithTs.triageFeature(feature.id); + expect(triaged.taskId).toMatch(/^ERR-\d+$/); + }); + /* FNXC:MissionWorkflows 2026-06-25-00:00: MissionStore tests pin the storage invariant: workflowId is applied only to newly created mission-triage tasks, while default inheritance and duplicate-task reuse keep their existing workflow behavior. diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index f0fc0198d3..dc6e84d63f 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -183,7 +183,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 139; +const SCHEMA_VERSION = 140; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -956,6 +956,7 @@ CREATE TABLE IF NOT EXISTS missions ( interviewState TEXT NOT NULL, baseBranch TEXT, branchStrategy TEXT, + taskPrefix TEXT, autoAdvance INTEGER DEFAULT 0, autoMerge INTEGER, createdAt TEXT NOT NULL, @@ -5610,6 +5611,14 @@ export class Database { }); } + if (version < 140) { + // Per-mission ticket id prefix (e.g. "ERR") for triaged tasks; NULL means + // inherit the project-wide taskPrefix setting. Additive-only, no backfill. + this.applyMigration(140, () => { + this.addColumnIfMissing("missions", "taskPrefix", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 077fc03ede..69c4ef7a01 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -233,6 +233,7 @@ interface MissionRow { interviewState: string; baseBranch: string | null; branchStrategy: string | null; + taskPrefix: string | null; autoMerge: number | null; autoAdvance: number; autopilotEnabled: number; @@ -435,6 +436,7 @@ export class MissionStore extends EventEmitter { interviewState: row.interviewState as InterviewState, baseBranch: row.baseBranch || undefined, branchStrategy, + taskPrefix: row.taskPrefix || undefined, autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge), autoAdvance: Boolean(row.autoAdvance), autopilotEnabled: Boolean(row.autopilotEnabled), @@ -661,6 +663,7 @@ export class MissionStore extends EventEmitter { interviewState: "not_started", baseBranch: input.baseBranch, branchStrategy: input.branchStrategy, + taskPrefix: input.taskPrefix, autoMerge: input.autoMerge, autoAdvance: false, autopilotEnabled: false, @@ -670,8 +673,8 @@ export class MissionStore extends EventEmitter { }; this.db.prepare(` - INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, taskPrefix, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( mission.id, mission.title, @@ -680,6 +683,7 @@ export class MissionStore extends EventEmitter { mission.interviewState, mission.baseBranch ?? null, mission.branchStrategy ? JSON.stringify(mission.branchStrategy) : null, + mission.taskPrefix ?? null, mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0), mission.autoAdvance ? 1 : 0, mission.autopilotEnabled ? 1 : 0, @@ -1276,6 +1280,7 @@ export class MissionStore extends EventEmitter { interviewState = ?, baseBranch = ?, branchStrategy = ?, + taskPrefix = ?, autoMerge = ?, autoAdvance = ?, autopilotEnabled = ?, @@ -1290,6 +1295,7 @@ export class MissionStore extends EventEmitter { updated.interviewState, updated.baseBranch ?? null, updated.branchStrategy ? JSON.stringify(updated.branchStrategy) : null, + updated.taskPrefix ?? null, updated.autoMerge === undefined ? null : (updated.autoMerge ? 1 : 0), updated.autoAdvance ? 1 : 0, updated.autopilotEnabled ? 1 : 0, @@ -3978,6 +3984,7 @@ export class MissionStore extends EventEmitter { description, branch: branchAssignment.workingBranch, baseBranch: resolvedBaseBranch, + taskPrefix: mission?.taskPrefix, ...(missionId ? { branchContext: { diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index e9ad8d56c0..3232fe1730 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -139,6 +139,8 @@ export interface Mission { baseBranch?: string; /** Mission triage branch strategy: auto-per-task => assignmentMode "per-task-derived"; existing/custom-new => shared branchName; project-default/absent => shared default behavior. */ branchStrategy?: MissionBranchStrategy; + /** Per-mission ticket id prefix for triaged tasks (e.g. "ERR"). Absent => inherit the project-wide taskPrefix setting. */ + taskPrefix?: string; /** State of the AI specification interview process */ interviewState: InterviewState; /** @@ -392,6 +394,8 @@ export interface MissionCreateInput { baseBranch?: string; /** Optional branch strategy applied as the default for mission triage operations. */ branchStrategy?: MissionBranchStrategy; + /** Optional per-mission ticket id prefix for triaged tasks (e.g. "ERR"); inherits the project setting when absent. */ + taskPrefix?: string; /** Optional mission-level auto-merge override for linked task branches. */ autoMerge?: boolean; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2958360fb8..e286ad6c2f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -4451,7 +4451,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }, ): Promise { const settings = await this.getSettingsFast(); - const prefix = (settings.taskPrefix || "FN").trim().toUpperCase(); + // A per-mission taskPrefix (threaded via TaskCreateInput at triage time) + // overrides the project-wide setting for this task's id reservation. + const prefix = (input.taskPrefix?.trim() || settings.taskPrefix || "FN").trim().toUpperCase(); const allocator = this.getDistributedTaskIdAllocator(); const nodeId = await this.resolveLocalNodeIdForTaskAllocation(); const reservation = await allocator.reserveDistributedTaskId({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a9e69963ce..b3b190e58e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2703,6 +2703,9 @@ export interface TaskCreateInput { branch?: string; /** Optional planning/mission branch-group metadata carried across related tasks. */ branchContext?: TaskBranchContext; + /** Transient minting hint: overrides the project taskPrefix for this task's id + * reservation (used by mission triage to honor a per-mission prefix). Not persisted. */ + taskPrefix?: string; /** Optional per-task auto-merge override. Undefined means no task-level override. */ autoMerge?: boolean; /** Durable source provenance for the originating external issue. */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 92cf9f9955..5fe2a27e83 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -7930,6 +7930,8 @@ export interface Mission { mode: "project-default" | "existing" | "custom-new" | "auto-per-task"; branchName?: string; }; + /** Per-mission ticket id prefix (e.g. "ERR"); undefined inherits the project prefix. */ + taskPrefix?: string; status: MissionStatus; interviewState: "not_started" | "in_progress" | "completed" | "needs_update"; autoAdvance?: boolean; @@ -8021,7 +8023,7 @@ export function fetchMissions(projectId?: string): Promise } /** Create a new mission */ -export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise { +export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"]; taskPrefix?: string }, projectId?: string): Promise { return api(withProjectId("/missions", projectId), { method: "POST", body: JSON.stringify(input), diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index b9a53f7ee0..68338c4c1c 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -294,6 +294,7 @@ interface MissionFormData { autopilotEnabled: boolean; baseBranch: string; branchStrategy: MissionBranchStrategy; + taskPrefix: string; } interface MilestoneFormData { @@ -326,6 +327,7 @@ const EMPTY_MISSION_FORM: MissionFormData = { branchStrategy: { mode: "project-default", }, + taskPrefix: "", }; const EMPTY_MILESTONE_FORM: MilestoneFormData = { @@ -1611,6 +1613,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr autopilotEnabled: mission.autopilotEnabled ?? false, baseBranch: mission.baseBranch ?? "", branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy), + taskPrefix: mission.taskPrefix ?? "", }); }, []); @@ -1648,6 +1651,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr autopilotEnabled: missionForm.autopilotEnabled, baseBranch: missionForm.baseBranch.trim() || undefined, branchStrategy, + taskPrefix: missionForm.taskPrefix.trim() || undefined, }, projectId); addToast(t("missions.created", "Mission created"), "success"); } else if (editingMissionId) { @@ -1660,6 +1664,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr autopilotEnabled: missionForm.autopilotEnabled, baseBranch: missionForm.baseBranch.trim() || "", branchStrategy, + taskPrefix: missionForm.taskPrefix.trim() || undefined, }; if (missionForm.autopilotEnabled) { updates.autoAdvance = true; @@ -2810,6 +2815,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")} /> +