From fd795883c51bede572594fe109219e90c3391c9b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 21:35:23 -0700 Subject: [PATCH] feat(missions): per-mission taskPrefix override for triaged task ids (#2347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Maintainer re-land of [#2334](https://github.com/Runfusion/Fusion/pull/2334) (fork `flexi767:feat/per-mission-task-prefix`) after resolving merge conflicts with current `main`. Fork push was unavailable despite `maintainerCanModify`, so this branch carries the conflict resolution. ### Feature - Optional per-mission `taskPrefix` for triaged task ids (inherits project prefix when unset) - Dashboard MissionManager + routes + store/triage plumbing - Postgres migration for `project.missions.task_prefix` ### Conflict resolution - Main claimed migration **0026** (bigint counters) and **0027** (workflow IR pin) - Mission task-prefix migration renumbered **0026 → 0028** - Baseline `0000_initial.sql` includes `task_prefix` on missions - `legacy.ts` keeps code-org re-exports; `missions.ts` carries `taskPrefix` on create/update types ## Test plan - [ ] CI green (lint/typecheck/build/gate) - [ ] Create mission with custom prefix; triage feature → task ids use that prefix - [ ] Clear mission prefix via PATCH null; new tasks inherit project prefix Closes / supersedes #2334 once this lands (or re-point the fork PR). ## Summary by CodeRabbit * **New Features** * Missions can now set an optional per-mission task ID prefix (overriding the project default). * Added task prefix support to mission create/edit UI and dashboard APIs, including normalized uppercase values and validation. * **Bug Fixes** * Improved commit hook generation for custom prefixes and special characters, with safer shell handling to prevent unsafe interpretation. * **Chores** * Added PostgreSQL migration and schema-applier support to persist and propagate mission task prefixes, including upgrade/backfill coverage. * **Tests** * Added backend and UI/API test coverage for task-prefix creation, clearing, and ID minting behavior. --- .changeset/mission-task-prefix.md | 7 + docs/missions.md | 6 + .../postgres/mission-store.pg.test.ts | 37 ++++ .../__tests__/postgres/schema-applier.test.ts | 6 + .../core/src/async-mission-store-queries.ts | 11 + packages/core/src/async-mission-store.ts | 3 + packages/core/src/mission-store.ts | 33 ++- packages/core/src/mission-types.ts | 10 + .../src/postgres/migrations/0000_initial.sql | 2 + .../migrations/0038_mission_task_prefix.sql | 18 ++ packages/core/src/postgres/schema-applier.ts | 31 ++- packages/core/src/postgres/schema/project.ts | 5 + .../task-store/__tests__/task-prefix.test.ts | 24 +++ packages/core/src/task-store/task-creation.ts | 4 +- packages/core/src/task-store/task-prefix.ts | 14 ++ .../task-store/workflow-task-create-ops.ts | 4 +- packages/core/src/types/task-core.ts | 6 + packages/dashboard/app/api/missions.ts | 9 +- .../app/components/MissionManager.tsx | 46 ++++ .../MissionManager.task-prefix.test.tsx | 180 ++++++++++++++++ .../dashboard/app/components/mission-types.ts | 6 + .../mission-task-prefix-routes.test.ts | 196 ++++++++++++++++++ packages/dashboard/src/mission-routes.ts | 25 ++- .../src/__tests__/worktree-hooks.test.ts | 55 ++++- packages/engine/src/worktree-hooks.ts | 35 +++- 25 files changed, 753 insertions(+), 20 deletions(-) create mode 100644 .changeset/mission-task-prefix.md create mode 100644 packages/core/src/postgres/migrations/0038_mission_task_prefix.sql create mode 100644 packages/core/src/task-store/__tests__/task-prefix.test.ts create mode 100644 packages/core/src/task-store/task-prefix.ts create mode 100644 packages/dashboard/app/components/__tests__/MissionManager.task-prefix.test.tsx create mode 100644 packages/dashboard/src/__tests__/mission-task-prefix-routes.test.ts diff --git a/.changeset/mission-task-prefix.md b/.changeset/mission-task-prefix.md new file mode 100644 index 0000000000..49d04d485e --- /dev/null +++ b/.changeset/mission-task-prefix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Allow missions to override the project task prefix for triaged task IDs. +category: feature +dev: Persists an optional mission prefix and threads it through distributed task-ID allocation and commit hooks. diff --git a/docs/missions.md b/docs/missions.md index 261bfb671a..6dd598bd73 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -107,6 +107,12 @@ Precedence order during triage: 2. Mission `baseBranch` 3. Project default branch resolution +### Mission task prefix override + +Missions support an optional `taskPrefix` field. When set, feature triage (`triageFeature` / `triageSlice`) passes it as a transient minting hint on `TaskCreateInput` so the distributed task-id allocator issues ids under that prefix. When unset or cleared, triage inherits the project-wide `settings.taskPrefix`. + +The Mission Manager create/edit form exposes this as **Task prefix** (empty = project default). Clearing a previously saved prefix on edit sends `taskPrefix: null` so the stored override is removed. + ### Mission branch strategy defaults Missions can also persist a `branchStrategy` used whenever triage is triggered without explicit branch options (manual triage and autopilot triage). diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index d0a24f6930..f87209f2c5 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -883,4 +883,41 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(await m.getMissionWithHierarchy("M-DOES-NOT-EXIST")).toBeUndefined(); expect(await m.getMissionHealth("M-DOES-NOT-EXIST")).toBeUndefined(); }); + + /* + FNXC:MissionTaskPrefix 2026-07-26-12:00: + Per-mission taskPrefix round-trips on create/update and clears to NULL so triage re-inherits the project prefix (PR #1930 / #2347). + */ + it("round-trips mission taskPrefix on create/update and clears to undefined", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Prefixed", taskPrefix: "ERR" }); + expect((await m.getMission(mission.id))?.taskPrefix).toBe("ERR"); + + const updated = await m.updateMission(mission.id, { taskPrefix: "BUG" }); + expect(updated.taskPrefix).toBe("BUG"); + expect((await m.getMission(mission.id))?.taskPrefix).toBe("BUG"); + + const cleared = await m.updateMission(mission.id, { taskPrefix: undefined }); + expect(cleared.taskPrefix).toBeUndefined(); + expect((await m.getMission(mission.id))?.taskPrefix).toBeUndefined(); + + const raw = (await h.layer().db.execute( + sql`SELECT task_prefix FROM project.missions WHERE id = ${mission.id}`, + )) as unknown as Array<{ task_prefix: string | null }>; + expect(raw[0]?.task_prefix).toBeNull(); + }); + + it("mints triaged task ids with the mission's taskPrefix", async () => { + const m = missions(); + await h.store().updateSettings({ taskPrefix: "FN" }); + const mission = await m.createMission({ title: "Mission", taskPrefix: "ERR" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Ship prefix", acceptanceCriteria: "id uses ERR" }); + + const triaged = await m.triageFeature(feature.id); + expect(triaged.taskId).toBeTruthy(); + expect(triaged.taskId).toMatch(/^ERR-\d+$/); + }); + }); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index ff42c6bc08..074014b09a 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -80,6 +80,7 @@ import { MISSION_LINEAGE_STOP_VERSION, CHAT_SESSION_TAGS_VERSION, DROP_GLOBAL_CONCURRENCY_VERSION, + MISSION_TASK_PREFIX_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -1677,6 +1678,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_LINEAGE_STOP_VERSION, CHAT_SESSION_TAGS_VERSION, DROP_GLOBAL_CONCURRENCY_VERSION, + MISSION_TASK_PREFIX_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1740,6 +1742,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_LINEAGE_STOP_VERSION, CHAT_SESSION_TAGS_VERSION, DROP_GLOBAL_CONCURRENCY_VERSION, + MISSION_TASK_PREFIX_VERSION, ]); }); @@ -1936,6 +1939,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_LINEAGE_STOP_VERSION, CHAT_SESSION_TAGS_VERSION, DROP_GLOBAL_CONCURRENCY_VERSION, + MISSION_TASK_PREFIX_VERSION, ]); }); @@ -2013,6 +2017,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_LINEAGE_STOP_VERSION, CHAT_SESSION_TAGS_VERSION, DROP_GLOBAL_CONCURRENCY_VERSION, + MISSION_TASK_PREFIX_VERSION, ]); }); @@ -2090,6 +2095,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_LINEAGE_STOP_VERSION, CHAT_SESSION_TAGS_VERSION, DROP_GLOBAL_CONCURRENCY_VERSION, + MISSION_TASK_PREFIX_VERSION, ]); }); }); diff --git a/packages/core/src/async-mission-store-queries.ts b/packages/core/src/async-mission-store-queries.ts index 1ef14e7c0b..e499ced0c9 100644 --- a/packages/core/src/async-mission-store-queries.ts +++ b/packages/core/src/async-mission-store-queries.ts @@ -124,6 +124,8 @@ interface MissionRow { interviewState: string; baseBranch: string | null; branchStrategy: string | null; + /** Per-mission ticket id prefix; null/absent inherits project settings.taskPrefix. */ + taskPrefix: string | null; autoMerge: number | null; autoAdvance: number | null; autopilotEnabled: number | null; @@ -284,6 +286,7 @@ const missionColumns = { interviewState: schema.project.missions.interviewState, baseBranch: schema.project.missions.baseBranch, branchStrategy: schema.project.missions.branchStrategy, + taskPrefix: schema.project.missions.taskPrefix, autoMerge: schema.project.missions.autoMerge, autoAdvance: schema.project.missions.autoAdvance, autopilotEnabled: schema.project.missions.autopilotEnabled, @@ -438,6 +441,8 @@ function rowToMission(row: MissionRow): Mission { interviewState: row.interviewState as InterviewState, baseBranch: row.baseBranch ?? undefined, branchStrategy, + // FNXC:MissionTaskPrefix 2026-07-26-12:00: match other nullable text fields (?? not ||) so empty-string rows stay distinguishable if validation ever relaxes. + taskPrefix: row.taskPrefix ?? undefined, autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge), autoAdvance: Boolean(row.autoAdvance ?? 0), autopilotEnabled: Boolean(row.autopilotEnabled ?? 0), @@ -642,6 +647,8 @@ export async function createMission( interviewState: input.interviewState, baseBranch: input.baseBranch ?? null, branchStrategy: serializeBranchStrategy(input.branchStrategy), + // FNXC:MissionTaskPrefix 2026-07-26-12:00: persist optional per-mission minting prefix; undefined/null stores NULL so triage inherits the project prefix. + taskPrefix: input.taskPrefix ?? null, autoMerge: input.autoMerge === undefined ? null : input.autoMerge ? 1 : 0, autoAdvance: input.autoAdvance ? 1 : 0, autopilotEnabled: input.autopilotEnabled ? 1 : 0, @@ -689,6 +696,8 @@ export async function updateMission( interviewState: mission.interviewState, baseBranch: mission.baseBranch ?? null, branchStrategy: serializeBranchStrategy(mission.branchStrategy), + // FNXC:MissionTaskPrefix 2026-07-26-12:00: write NULL when cleared so the mission re-inherits the project prefix. + taskPrefix: mission.taskPrefix ?? null, autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0, autoAdvance: mission.autoAdvance ? 1 : 0, autopilotEnabled: mission.autopilotEnabled ? 1 : 0, @@ -1878,6 +1887,7 @@ export async function upsertMission(handle: QueryHandle, mission: Mission): Prom interviewState: mission.interviewState, baseBranch: mission.baseBranch ?? null, branchStrategy: serializeBranchStrategy(mission.branchStrategy), + taskPrefix: mission.taskPrefix ?? null, autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0, autoAdvance: mission.autoAdvance ? 1 : 0, autopilotEnabled: mission.autopilotEnabled ? 1 : 0, @@ -1895,6 +1905,7 @@ export async function upsertMission(handle: QueryHandle, mission: Mission): Prom interviewState: sql`excluded.interview_state`, baseBranch: sql`excluded.base_branch`, branchStrategy: sql`excluded.branch_strategy`, + taskPrefix: sql`excluded.task_prefix`, autoMerge: sql`excluded.auto_merge`, autoAdvance: sql`excluded.auto_advance`, autopilotEnabled: sql`excluded.autopilot_enabled`, diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 1ce9f95d6c..95e854fdef 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -261,6 +261,7 @@ export class AsyncMissionStore extends EventEmitter { description: input.description, baseBranch: input.baseBranch, branchStrategy: input.branchStrategy, + taskPrefix: input.taskPrefix, autoMerge: input.autoMerge, status: "planning", interviewState: "not_started", @@ -2300,6 +2301,8 @@ export class AsyncMissionStore extends EventEmitter { An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override. */ ...(mission?.autoMerge === false ? { autoMerge: false } : {}), + // FNXC:MissionTaskPrefix 2026-07-26-12:00: thread the mission's optional taskPrefix into TaskCreateInput so the distributed allocator mints ERR-N (etc.) instead of the project prefix. + ...(mission?.taskPrefix ? { taskPrefix: mission.taskPrefix } : {}), ...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}), }); if (guard.fingerprint) { diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 5fee8bae2d..682b30f4cd 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -234,6 +234,7 @@ interface MissionRow { interviewState: string; baseBranch: string | null; branchStrategy: string | null; + taskPrefix: string | null; autoMerge: number | null; autoAdvance: number; autopilotEnabled: number; @@ -406,6 +407,7 @@ export class MissionStore extends EventEmitter { super(); this.setMaxListeners(100); this.ensureMissionContractAssertionColumns(); + this.ensureMissionTaskPrefixColumn(); // Initialize sequence counter from existing events to ensure uniqueness across restarts const lastEvent = this.db.prepare(` SELECT seq FROM mission_events ORDER BY seq DESC LIMIT 1 @@ -452,6 +454,24 @@ export class MissionStore extends EventEmitter { `).run(); } + /* + FNXC:MissionTaskPrefix 2026-07-26-12:00: + Sync-store test doubles and residual SQLite surfaces need the optional column + before create/update write taskPrefix. Additive IF-missing ALTER keeps older + in-memory fixtures usable without a full schema rebuild. + */ + private ensureMissionTaskPrefixColumn(): void { + const schemaStatement = this.db.prepare("PRAGMA table_info(missions)") as unknown as { + all?: () => Array<{ name?: string }>; + }; + const columns = schemaStatement.all?.(); + if (!Array.isArray(columns) || columns.length === 0) return; + const names = new Set(columns.map((column) => column.name)); + if (!names.has("taskPrefix")) { + this.db.prepare("ALTER TABLE missions ADD COLUMN taskPrefix TEXT").run(); + } + } + // ── Row-to-Object Converters ─────────────────────────────────────── /** @@ -475,6 +495,8 @@ export class MissionStore extends EventEmitter { interviewState: row.interviewState as InterviewState, baseBranch: row.baseBranch || undefined, branchStrategy, + // FNXC:MissionTaskPrefix 2026-07-26-12:00: match nullable text fields (?? not ||); keep parity with async-mission-store-queries rowToMission. + taskPrefix: row.taskPrefix ?? undefined, autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge), autoAdvance: Boolean(row.autoAdvance), autopilotEnabled: Boolean(row.autopilotEnabled), @@ -703,6 +725,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, @@ -712,8 +735,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, @@ -722,6 +745,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, @@ -1343,11 +1367,12 @@ export class MissionStore extends EventEmitter { this.db.prepare(` UPDATE missions SET title = ?, description = ?, status = ?, interviewState = ?, baseBranch = ?, branchStrategy = ?, - autoMerge = ?, autoAdvance = ?, autopilotEnabled = ?, autopilotState = ?, + taskPrefix = ?, autoMerge = ?, autoAdvance = ?, autopilotEnabled = ?, autopilotState = ?, lastAutopilotActivityAt = ?, updatedAt = ? WHERE id = ? `).run( updated.title, updated.description ?? null, updated.status, 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, updated.autopilotState ?? "inactive", updated.lastAutopilotActivityAt ?? null, updated.updatedAt, updated.id, @@ -4224,6 +4249,8 @@ export class MissionStore extends EventEmitter { An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override. */ ...(mission?.autoMerge === false ? { autoMerge: false } : {}), + // FNXC:MissionTaskPrefix 2026-07-26-12:00: thread the mission's optional taskPrefix into TaskCreateInput for distributed id minting. + ...(mission?.taskPrefix ? { taskPrefix: mission.taskPrefix } : {}), ...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}), }); diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index e5c6fcc8be..f6542b31e5 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -310,6 +310,11 @@ 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; + /** + * FNXC:MissionTaskPrefix 2026-07-26-12:00: + * 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; /** @@ -579,6 +584,11 @@ export interface MissionCreateInput { baseBranch?: string; /** Optional branch strategy applied as the default for mission triage operations. */ branchStrategy?: MissionBranchStrategy; + /** + * FNXC:MissionTaskPrefix 2026-07-26-12:00: + * 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/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 054f815a4e..5378641777 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -872,6 +872,8 @@ CREATE TABLE IF NOT EXISTS project.missions ( branch_strategy text, auto_advance integer DEFAULT 0, auto_merge integer, + -- FNXC:MissionTaskPrefix 2026-07-26-12:00: optional per-mission ticket prefix; NULL inherits project settings.taskPrefix + task_prefix text, autopilot_enabled integer NOT NULL DEFAULT 0, autopilot_state text NOT NULL DEFAULT 'inactive', last_autopilot_activity_at text, diff --git a/packages/core/src/postgres/migrations/0038_mission_task_prefix.sql b/packages/core/src/postgres/migrations/0038_mission_task_prefix.sql new file mode 100644 index 0000000000..e6f8ab378e --- /dev/null +++ b/packages/core/src/postgres/migrations/0038_mission_task_prefix.sql @@ -0,0 +1,18 @@ +/* +FNXC:MissionTaskPrefix 2026-07-26-12:00: +Per-mission taskPrefix override (PR #1930 / #2347). Missions may set a letter-led +alphanumeric prefix used when triaging features into tasks; NULL means inherit +the project-wide settings.taskPrefix. Additive only — no backfill. +Renumbered to 0037 after main claimed 0031–0036 (workflow continuations through +chat session tags). Existing databases that already applied 0000–0036 must receive +this column via an independent version so upgrade paths cannot skip it. +*/ + +DO $$ +BEGIN + IF to_regclass('project.missions') IS NOT NULL THEN + ALTER TABLE project.missions + ADD COLUMN IF NOT EXISTS task_prefix text; + END IF; +END +$$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index c426a5cc7f..a13a13411f 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -49,8 +49,12 @@ FNXC:TaskWedgeNotifications 2026-07-23-00:00: Advance the PostgreSQL schema ceiling for the durable wedge episode column. The forward migration must run before TaskStore writes the new field on fresh and upgraded databases. + +FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main): +SCHEMA_BASELINE_VERSION advances to 0038 for optional per-mission task_prefix — 0037 is the +capacity-model table drop that landed while this PR was open. */ -export const SCHEMA_BASELINE_VERSION = "0037"; +export const SCHEMA_BASELINE_VERSION = "0038"; /** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -160,6 +164,15 @@ runs — which is exactly what happened on the first attempt here: the file exis the model was updated, and the table was still present in a fresh database. */ export const DROP_GLOBAL_CONCURRENCY_VERSION = "0037"; +/* +FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main — RENUMBERED 0037 -> 0038): +Upgraded projects need the optional mission prefix before mission reads and triage task creation use +it. This shipped as 0037 when the PR was written; main has since taken 0037 for the capacity-model +table drop, and two migrations cannot share a number — the runner keys bookkeeping on it, so the +second would read as already-applied and silently never run. Renumbered rather than reordered: 0037 +is landed on real databases and its identity is immutable, per the MONITOR_APPROVAL note above. +*/ +export const MISSION_TASK_PREFIX_VERSION = "0038"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -371,6 +384,7 @@ const MILESTONE_ASSERTION_PROVENANCE_MIGRATION_PATH = join( const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_mission_lineage_stop.sql"); const CHAT_SESSION_TAGS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0036_chat_session_tags.sql"); const DROP_GLOBAL_CONCURRENCY_MIGRATION_PATH = join(MIGRATIONS_DIR, "0037_drop_global_concurrency.sql"); +const MISSION_TASK_PREFIX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0038_mission_task_prefix.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -478,6 +492,7 @@ export async function applySchemaBaseline( const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION); const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION); const dropGlobalConcurrencyAlreadyApplied = applied.includes(DROP_GLOBAL_CONCURRENCY_VERSION); + const missionTaskPrefixAlreadyApplied = applied.includes(MISSION_TASK_PREFIX_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -1013,6 +1028,20 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* + FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main): + Apply missions.task_prefix independently so databases that already recorded an earlier version + gain the optional mission namespace before mission reads or task minting. Sequenced AFTER the + capacity-model drop rather than merged with it: the two are unrelated, and a shared guard would + make either one's bookkeeping row suppress the other's SQL. + */ + if (!missionTaskPrefixAlreadyApplied) { + const migrationSql = await readFile(MISSION_TASK_PREFIX_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_TASK_PREFIX_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 7b3b7e2797..28508301f8 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1258,6 +1258,11 @@ export const missions = projectSchema.table("missions", { branchStrategy: text("branch_strategy"), autoAdvance: integer("auto_advance").default(0), autoMerge: integer("auto_merge"), + /* + FNXC:MissionTaskPrefix 2026-07-26-12:00: + Optional per-mission ticket id prefix (e.g. ERR). Absent/null inherits the project-wide taskPrefix setting so one mission can mint distinct ids without flipping the board-wide prefix (PR #1930 / #2347, ported onto PG after SQLite cutover). + */ + taskPrefix: text("task_prefix"), // FNXC:MissionStore 2026-06-24-08:00: // Autopilot columns were added via addColumnIfMissing in SQLite migrations // (db.ts SCHEMA_VERSION=128) but were missing from the initial U3 snapshot. diff --git a/packages/core/src/task-store/__tests__/task-prefix.test.ts b/packages/core/src/task-store/__tests__/task-prefix.test.ts new file mode 100644 index 0000000000..f4f929fd62 --- /dev/null +++ b/packages/core/src/task-store/__tests__/task-prefix.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { resolveTaskPrefix } from "../task-prefix.js"; + +/* +FNXC:MissionTaskPrefix 2026-07-26-12:00: +Shared helper used by createTaskWithDistributedReservation (FN fallback) and +createTaskBackend (KB fallback). Keep preference order: input → settings → fallback. +*/ +describe("resolveTaskPrefix", () => { + it("prefers a non-empty mission input hint over settings and fallback", () => { + expect(resolveTaskPrefix(" err ", "FN", "KB")).toBe("ERR"); + }); + + it("falls back to settings when the input hint is blank", () => { + expect(resolveTaskPrefix(" ", "fn-board", "KB")).toBe("FN-BOARD"); + expect(resolveTaskPrefix(undefined, "fn-board", "KB")).toBe("FN-BOARD"); + }); + + it("uses the path-specific fallback when neither hint nor settings are set", () => { + expect(resolveTaskPrefix(undefined, undefined, "FN")).toBe("FN"); + expect(resolveTaskPrefix(undefined, undefined, "KB")).toBe("KB"); + expect(resolveTaskPrefix(undefined, " ", "KB")).toBe("KB"); + }); +}); diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index f27f2dae22..ad172964b1 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -36,6 +36,7 @@ import {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js"; import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js"; import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js"; import type {DbTransaction} from "../postgres/data-layer.js"; +import { resolveTaskPrefix } from "./task-prefix.js"; type CreateTaskWithAfterInsert = TaskCreateInput & { /** Internal transaction hook; never persisted in task source metadata. */ @@ -292,7 +293,8 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI // failure it aborts the reservation so the sequence is not wasted. const allocator = store.getDistributedTaskIdAllocator(); const settings = await store.getSettingsFast(); - const prefix = (settings.taskPrefix || "KB").trim().toUpperCase(); + // FNXC:MissionTaskPrefix 2026-07-26-12:00: backend task creation must honor the transient mission prefix hint before project settings and the KB fallback. + const prefix = resolveTaskPrefix(input.taskPrefix, settings.taskPrefix, "KB"); const nodeId = await store.resolveLocalNodeIdForTaskAllocation(); const reservation = await allocator.reserveDistributedTaskId({ prefix, diff --git a/packages/core/src/task-store/task-prefix.ts b/packages/core/src/task-store/task-prefix.ts new file mode 100644 index 0000000000..4d8f3ca14e --- /dev/null +++ b/packages/core/src/task-store/task-prefix.ts @@ -0,0 +1,14 @@ +/** + * FNXC:MissionTaskPrefix 2026-07-26-12:00: + * Shared minting-prefix resolution for createTask paths. Prefer the per-mission + * TaskCreateInput.taskPrefix hint (mission triage), then project settings.taskPrefix, + * then the path-specific fallback. Extracted so FN default (workflow-task-create-ops) + * and KB default (task-creation) cannot drift (CodeRabbit #2347 / PR #1930). + */ +export function resolveTaskPrefix( + taskPrefixHint: string | undefined, + settingsTaskPrefix: string | undefined, + fallback: string, +): string { + return (taskPrefixHint?.trim() || settingsTaskPrefix?.trim() || fallback).trim().toUpperCase(); +} diff --git a/packages/core/src/task-store/workflow-task-create-ops.ts b/packages/core/src/task-store/workflow-task-create-ops.ts index b106237f1d..48d2f2769e 100644 --- a/packages/core/src/task-store/workflow-task-create-ops.ts +++ b/packages/core/src/task-store/workflow-task-create-ops.ts @@ -18,6 +18,7 @@ import {randomUUID} from "node:crypto"; import {and, eq, inArray, isNull} from "drizzle-orm"; import {filterArchived as filterArchivedAsync} from "../async-archive-db.js"; import type {Task, TaskCreateInput, Column, ColumnId, TaskDocumentWithTask, RunMutationContext, TaskCommitAssociation, GoalCitation, GoalCitationInput, TaskBranchAssignmentMode, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind} from "../types.js"; +import { resolveTaskPrefix } from "./task-prefix.js"; import {COLUMNS} from "../types.js"; import {parseWorkflowIr, serializeWorkflowIr} from "../workflow-ir.js"; import {resolveAllowedColumns, workflowHasColumn} from "../workflow-transitions.js"; @@ -149,7 +150,8 @@ export async function atomicWriteTaskJsonImpl2(store: TaskStore, dir: string, ta export async function createTaskWithDistributedReservationImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; createTaskWithId?: (taskId: string) => Promise; },): Promise { const settings = await store.getSettingsFast(); - const prefix = (settings.taskPrefix || "FN").trim().toUpperCase(); + // FNXC:MissionTaskPrefix 2026-07-26-12:00: prefer TaskCreateInput.taskPrefix (mission triage minting hint) over the project-wide settings.taskPrefix so a single mission can use e.g. ERR- while the board stays FN-. + const prefix = resolveTaskPrefix(input.taskPrefix, settings.taskPrefix, "FN"); const allocator = store.getDistributedTaskIdAllocator(); const nodeId = await store.resolveLocalNodeIdForTaskAllocation(); const reservation = await allocator.reserveDistributedTaskId({ diff --git a/packages/core/src/types/task-core.ts b/packages/core/src/types/task-core.ts index 2ce0e1b721..cc7fe53409 100644 --- a/packages/core/src/types/task-core.ts +++ b/packages/core/src/types/task-core.ts @@ -1306,6 +1306,12 @@ export interface TaskCreateInput { branch?: string; /** Optional planning/mission branch-group metadata carried across related tasks. */ branchContext?: TaskBranchContext; + /** + * FNXC:MissionTaskPrefix 2026-07-26-12:00: + * 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/missions.ts b/packages/dashboard/app/api/missions.ts index d8471eaced..8bd0e19574 100644 --- a/packages/dashboard/app/api/missions.ts +++ b/packages/dashboard/app/api/missions.ts @@ -47,6 +47,11 @@ export interface Mission { status: MissionStatus; interviewState: "not_started" | "in_progress" | "completed" | "needs_update"; autoAdvance?: boolean; + /** + * FNXC:MissionTaskPrefix 2026-07-26-12:00: + * Optional per-mission ticket id prefix for triaged tasks. Absent/null inherits project settings.taskPrefix. + */ + taskPrefix?: string | null; /** * FNXC:MissionAutoMerge 2026-07-19-12:30: * Mission-level auto-merge override (create/update payloads + list/detail responses). @@ -141,7 +146,7 @@ export function fetchMissions(projectId?: string): Promise } /** Create a new mission */ -export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; autoMerge?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise { +export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; autoMerge?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"]; taskPrefix?: string | null }, projectId?: string): Promise { return api(withProjectId("/missions", projectId), { method: "POST", body: JSON.stringify(input), @@ -154,7 +159,7 @@ export function fetchMission(missionId: string, projectId?: string): Promise, projectId?: string): Promise { +export function updateMission(missionId: string, updates: Partial> & { taskPrefix?: string | null; autoMerge?: boolean | null }, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 735dc0f36a..7d5aa1b452 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -302,6 +302,7 @@ interface MissionFormData { autoMergeOverride: MissionAutoMergeOverride; baseBranch: string; branchStrategy: MissionBranchStrategy; + taskPrefix: string; } interface MilestoneFormData { @@ -335,6 +336,7 @@ const EMPTY_MISSION_FORM: MissionFormData = { branchStrategy: { mode: "project-default", }, + taskPrefix: "", }; const EMPTY_MILESTONE_FORM: MilestoneFormData = { @@ -1694,6 +1696,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr ]); // Mission handlers + const handleMissionTaskPrefixChange = useCallback((rawValue: string) => { + const taskPrefix = rawValue.toUpperCase(); + /** FNXC:MissionTaskPrefix 2026-07-26-12:00: keep all mission forms aligned with server validation so invalid prefixes never enter client state. */ + if (taskPrefix !== "" && !/^[A-Z][A-Z0-9]*$/.test(taskPrefix)) return; + setMissionForm((current) => ({ ...current, taskPrefix })); + }, []); + const handleEditMission = useCallback((mission: Mission) => { setEditingMissionId(mission.id); setIsCreatingMission(false); @@ -1705,6 +1714,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr autoMergeOverride: missionAutoMergeOverride(mission.autoMerge), baseBranch: mission.baseBranch ?? "", branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy), + taskPrefix: mission.taskPrefix ?? "", }); }, []); @@ -1745,6 +1755,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr : {}), baseBranch: missionForm.baseBranch.trim() || undefined, branchStrategy, + taskPrefix: missionForm.taskPrefix.trim() || undefined, }, projectId); addToast(t("missions.created", "Mission created"), "success"); } else if (editingMissionId) { @@ -1759,6 +1770,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) ?? null, baseBranch: missionForm.baseBranch.trim() || "", branchStrategy, + /* + FNXC:MissionTaskPrefix 2026-07-26-12:00: + Edit-save must send taskPrefix:null when the field is cleared. Empty input must not map to undefined: JSON.stringify drops undefined keys, the PATCH route treats a missing key as "no change". + */ + taskPrefix: missionForm.taskPrefix.trim() || null, }; if (missionForm.autopilotEnabled) { updates.autoAdvance = true; @@ -2932,6 +2948,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")} /> +