feat(missions): per-mission taskPrefix override for triaged task ids (#2347)

## 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).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-30 21:35:23 -07:00
committed by GitHub
parent e6e70a2562
commit fd795883c5
25 changed files with 753 additions and 20 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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+$/);
});
});

View File

@@ -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,
]);
});
});

View File

@@ -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`,

View File

@@ -261,6 +261,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
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<MissionStoreEvents> {
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) {

View File

@@ -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<MissionStoreEvents> {
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<MissionStoreEvents> {
`).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<MissionStoreEvents> {
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<MissionStoreEvents> {
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<MissionStoreEvents> {
};
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<MissionStoreEvents> {
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<MissionStoreEvents> {
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<MissionStoreEvents> {
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 } : {}),
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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
$$;

View File

@@ -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 };
});
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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<string | null>; settings?: { autoSummarizeTitles?: boolean }; createTaskWithId?: (taskId: string) => Promise<Task>; },): Promise<Task> {
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({

View File

@@ -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. */

View File

@@ -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<MissionWithSummary[]>
}
/** 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<Mission> {
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<Mission> {
return api<Mission>(withProjectId("/missions", projectId), {
method: "POST",
body: JSON.stringify(input),
@@ -154,7 +159,7 @@ export function fetchMission(missionId: string, projectId?: string): Promise<Mis
}
/** Update mission */
export function updateMission(missionId: string, updates: Partial<Mission>, projectId?: string): Promise<Mission> {
export function updateMission(missionId: string, updates: Partial<Omit<Mission, "taskPrefix" | "autoMerge">> & { taskPrefix?: string | null; autoMerge?: boolean | null }, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),

View File

@@ -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")}
/>
</label>
<label>
{t("missions.taskPrefix", "Task prefix")}
<input
type="text"
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
value={missionForm.taskPrefix}
onChange={(e) => handleMissionTaskPrefixChange(e.target.value)}
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
@@ -4699,6 +4725,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.taskPrefix", "Task prefix")}
<input
type="text"
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
value={missionForm.taskPrefix}
onChange={(e) => handleMissionTaskPrefixChange(e.target.value)}
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
@@ -4808,6 +4844,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.taskPrefix", "Task prefix")}
<input
type="text"
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
value={missionForm.taskPrefix}
onChange={(e) => handleMissionTaskPrefixChange(e.target.value)}
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select

View File

@@ -0,0 +1,180 @@
/*
FNXC:MissionTaskPrefix 2026-07-14-12:00:
Regression for greptile P1 on PR #1930: clearing a mission taskPrefix on edit must PATCH null so the stored override is removed and triage re-inherits the project prefix.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionManager } from "../MissionManager";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchMissionEvents = vi.fn();
const mockFetchAssertions = vi.fn();
const mockFetchMilestoneValidation = vi.fn();
const mockFetchMilestoneValidationTelemetry = vi.fn();
const mockFetchValidationLoopState = vi.fn();
const mockFetchValidationRuns = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchAiSession = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
const mockUpdateMission = vi.fn();
const mockSubscribeSse = vi.fn(() => vi.fn());
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => "desktop",
isMobileViewport: () => false,
useViewportMode: () => "desktop",
}));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
}));
vi.mock("../MissionInterviewModal", () => ({
MissionInterviewModal: () => null,
}));
vi.mock("../MilestoneSliceInterviewModal", () => ({
MilestoneSliceInterviewModal: () => null,
}));
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchMissionEvents: (...args: unknown[]) => mockFetchMissionEvents(...args),
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
fetchValidationLoopState: (...args: unknown[]) => mockFetchValidationLoopState(...args),
fetchValidationRuns: (...args: unknown[]) => mockFetchValidationRuns(...args),
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
updateMission: (...args: unknown[]) => mockUpdateMission(...args),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
};
});
const projectId = "project-1";
const mission = {
id: "M-001",
title: "Prefixed Mission",
description: "Has a mission-level prefix",
status: "planning",
interviewState: "not_started",
taskPrefix: "ERR",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
function setupMocks() {
mockFetchMissions.mockResolvedValue([mission]);
mockFetchMission.mockResolvedValue(mission);
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchMissionEvents.mockResolvedValue([]);
mockFetchAssertions.mockResolvedValue([]);
mockFetchMilestoneValidation.mockResolvedValue(null);
mockFetchMilestoneValidationTelemetry.mockResolvedValue(null);
mockFetchValidationLoopState.mockResolvedValue(null);
mockFetchValidationRuns.mockResolvedValue([]);
mockFetchAiSessions.mockResolvedValue([]);
mockFetchAiSession.mockResolvedValue(null);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockUpdateMission.mockResolvedValue({ ...mission, taskPrefix: undefined });
}
describe("MissionManager taskPrefix clear", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setupMocks();
});
it("PATCHes taskPrefix:null when the edit field is cleared", async () => {
render(
<ConfirmDialogProvider>
<MissionManager isInline isOpen onClose={() => {}} addToast={vi.fn()} projectId={projectId} />
</ConfirmDialogProvider>,
);
const listItem = (await screen.findByText("Prefixed Mission")).closest(".mission-list__item");
expect(listItem).not.toBeNull();
fireEvent.click(listItem as HTMLElement);
await waitFor(() => {
expect(mockFetchMission).toHaveBeenCalledWith("M-001", projectId);
});
const editButtons = screen.getAllByRole("button", { name: "Edit mission" });
fireEvent.click(editButtons[0]);
const prefixInput = await screen.findByLabelText("Mission task prefix");
expect(prefixInput).toHaveValue("ERR");
fireEvent.change(prefixInput, { target: { value: "" } });
expect(prefixInput).toHaveValue("");
const formCard = prefixInput.closest(".mission-form-card");
expect(formCard).not.toBeNull();
fireEvent.click(within(formCard as HTMLElement).getByRole("button", { name: /Update/i }));
await waitFor(() => {
expect(mockUpdateMission).toHaveBeenCalled();
});
const [missionId, updates, calledProjectId] = mockUpdateMission.mock.calls[0] as [
string,
Record<string, unknown>,
string?,
];
expect(missionId).toBe("M-001");
expect(calledProjectId).toBe(projectId);
expect(updates).toEqual(
expect.objectContaining({
taskPrefix: null,
}),
);
// JSON wire format must retain the key; undefined would drop it and skip the clear.
expect(JSON.stringify(updates)).toContain('"taskPrefix":null');
});
it("uppercases valid prefixes and rejects invalid characters before they enter form state", async () => {
render(
<ConfirmDialogProvider>
<MissionManager isInline isOpen onClose={() => {}} addToast={vi.fn()} projectId={projectId} />
</ConfirmDialogProvider>,
);
const listItem = (await screen.findByText("Prefixed Mission")).closest(".mission-list__item");
fireEvent.click(listItem as HTMLElement);
await waitFor(() => expect(mockFetchMission).toHaveBeenCalledWith("M-001", projectId));
fireEvent.click(screen.getAllByRole("button", { name: "Edit mission" })[0]);
const prefixInput = await screen.findByLabelText("Mission task prefix");
fireEvent.change(prefixInput, { target: { value: "err2" } });
expect(prefixInput).toHaveValue("ERR2");
fireEvent.change(prefixInput, { target: { value: "1ERR" } });
expect(prefixInput).toHaveValue("ERR2");
fireEvent.change(prefixInput, { target: { value: "ERR-2" } });
expect(prefixInput).toHaveValue("ERR2");
fireEvent.change(prefixInput, { target: { value: "" } });
expect(prefixInput).toHaveValue("");
});
});

View File

@@ -61,6 +61,12 @@ export interface Mission {
};
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
/**
* FNXC:MissionTaskPrefix 2026-07-26-12:00:
* Optional per-mission ticket id prefix for triaged tasks. Absent/null inherits project settings.taskPrefix.
* Keep in sync with app/api/missions.ts Mission.taskPrefix.
*/
taskPrefix?: string | null;
/**
* FNXC:MissionAutoMerge 2026-07-19-12:30:
* Mission-level auto-merge override for linked task branches.

View File

@@ -0,0 +1,196 @@
// @vitest-environment node
/*
FNXC:MissionTaskPrefix 2026-07-14-19:00:
Route regression for greptile P1 on PR #1930: PATCH with taskPrefix null/empty must clear a stored mission override so triage inherits the project prefix; omitting the key must leave it unchanged.
FNXC:MissionTaskPrefix 2026-07-14-19:05:
Ported off the deleted SQLite inMemoryDb TaskStore fixture (VAL-REMOVAL-005). Mock the mission store so the test asserts route validation/normalization without requiring PostgreSQL.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
import { request } from "../test-request.js";
type MissionRecord = {
id: string;
title: string;
description?: string;
status: string;
interviewState: string;
taskPrefix?: string;
autoAdvance: boolean;
autopilotEnabled: boolean;
autopilotState: string;
createdAt: string;
updatedAt: string;
};
function createFixture() {
const missions = new Map<string, MissionRecord>();
let seq = 0;
const missionStore = {
createMission: vi.fn(async (input: { title: string; taskPrefix?: string }) => {
const now = new Date().toISOString();
const mission: MissionRecord = {
id: `M-${++seq}`,
title: input.title,
status: "planning",
interviewState: "not_started",
taskPrefix: input.taskPrefix,
autoAdvance: false,
autopilotEnabled: false,
autopilotState: "inactive",
createdAt: now,
updatedAt: now,
};
missions.set(mission.id, mission);
return mission;
}),
getMission: vi.fn(async (id: string) => missions.get(id)),
updateMission: vi.fn(async (id: string, updates: Partial<MissionRecord>) => {
const existing = missions.get(id);
if (!existing) throw new Error(`Mission ${id} not found`);
const updated: MissionRecord = {
...existing,
...updates,
id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
// Explicit undefined clears the override (same as AsyncMissionStore).
if ("taskPrefix" in updates && updates.taskPrefix === undefined) {
delete updated.taskPrefix;
}
missions.set(id, updated);
return updated;
}),
listMissions: vi.fn(async () => [...missions.values()]),
listMissionsWithSummaries: vi.fn(async () => []),
getMissionWithHierarchy: vi.fn(async () => undefined),
listGoalIdsForMission: vi.fn(async () => []),
linkGoal: vi.fn(),
unlinkGoal: vi.fn(),
on: vi.fn(),
off: vi.fn(),
};
const store = {
getMissionStore: () => missionStore,
getGoalStore: () => ({
getGoal: vi.fn(async () => undefined),
listGoals: vi.fn(async () => []),
}),
getRootDir: () => "/tmp/mission-task-prefix-routes",
getSettings: vi.fn(async () => ({})),
backendMode: true,
} as unknown as TaskStore;
const app = express();
app.use(express.json());
app.use("/api/missions", createMissionRouter(store));
return { app, missionStore, missions };
}
describe("mission taskPrefix routes", () => {
let app: express.Express;
let missionStore: ReturnType<typeof createFixture>["missionStore"];
beforeEach(() => {
({ app, missionStore } = createFixture());
});
it("clears a stored taskPrefix when PATCH sends null", async () => {
const mission = await missionStore.createMission({ title: "Prefixed", taskPrefix: "ERR" });
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBe("ERR");
const response = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ taskPrefix: null }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as { taskPrefix?: string }).taskPrefix).toBeUndefined();
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBeUndefined();
expect(missionStore.updateMission).toHaveBeenCalledWith(
mission.id,
expect.objectContaining({ taskPrefix: undefined }),
expect.anything(),
);
});
it("clears a stored taskPrefix when PATCH sends an empty string", async () => {
const mission = await missionStore.createMission({ title: "Prefixed", taskPrefix: "BUG" });
const response = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ taskPrefix: " " }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBeUndefined();
});
it("leaves taskPrefix unchanged when the PATCH body omits the key", async () => {
const mission = await missionStore.createMission({ title: "Prefixed", taskPrefix: "ERR" });
const response = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ title: "Renamed only" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as { title: string; taskPrefix?: string }).title).toBe("Renamed only");
expect((response.body as { taskPrefix?: string }).taskPrefix).toBe("ERR");
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBe("ERR");
// updateMission must not receive a taskPrefix key when the body omitted it.
const updateArg = missionStore.updateMission.mock.calls.at(-1)?.[1] as Record<string, unknown>;
expect(updateArg).not.toHaveProperty("taskPrefix");
});
it("uppercases a non-empty taskPrefix on PATCH", async () => {
const mission = await missionStore.createMission({ title: "Plain" });
const ok = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ taskPrefix: "err2" }),
{ "content-type": "application/json" },
);
expect(ok.status).toBe(200);
expect((ok.body as { taskPrefix?: string }).taskPrefix).toBe("ERR2");
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBe("ERR2");
});
it.each([
[123, "taskPrefix must be a string or null"],
["1ERR", "taskPrefix must start with a letter and contain only letters and digits"],
])("returns 400 for invalid taskPrefix %j", async (taskPrefix, message) => {
const mission = await missionStore.createMission({ title: "Plain" });
const response = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ taskPrefix }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: message });
expect(missionStore.updateMission).not.toHaveBeenCalled();
});
});

View File

@@ -248,6 +248,23 @@ function validateMissionBranchStrategy(value: unknown): MissionBranchStrategy |
};
}
/*
FNXC:MissionTaskPrefix 2026-07-26-12:00:
PATCH/POST accept taskPrefix as a string, empty string, or null. null/empty normalizes to undefined so MissionStore writes NULL and the mission inherits the project-wide prefix. The key must still be present on PATCH (null, not omitted) so clearing is distinct from "leave unchanged" (greptile P1 on PR #1930).
*/
function validateTaskPrefix(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw badRequest("taskPrefix must be a string or null");
}
const trimmed = value.trim().toUpperCase();
if (!trimmed) return undefined;
if (!/^[A-Z][A-Z0-9]*$/.test(trimmed)) {
throw badRequest("taskPrefix must start with a letter and contain only letters and digits");
}
return trimmed;
}
function validateOrderedIds(body: unknown): string[] {
if (!body || typeof body !== "object") {
throw new Error("Request body must contain orderedIds array");
@@ -506,7 +523,7 @@ export function createMissionRouter(
router.post(
"/",
catchTypedHandler(async (req, res) => {
const { title, description, autoAdvance, autoMerge, baseBranch, branchStrategy, goalIds } = req.body;
const { title, description, autoAdvance, autoMerge, baseBranch, branchStrategy, taskPrefix, goalIds } = req.body;
const validatedTitle = validateTitle(title);
const validatedDescription = validateDescription(description);
@@ -517,6 +534,7 @@ export function createMissionRouter(
description: validatedDescription,
baseBranch: validateDescription(baseBranch),
branchStrategy: validateMissionBranchStrategy(branchStrategy),
taskPrefix: validateTaskPrefix(taskPrefix),
...(autoMerge !== undefined
? {
// FNXC:MissionAutoMerge 2026-07-18-12:00: Create accepts only a real boolean; null is reserved for PATCH clear-to-inherited.
@@ -1158,7 +1176,7 @@ export function createMissionRouter(
"/:missionId",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const { title, description, status, autoAdvance, autoMerge, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
const { title, description, status, autoAdvance, autoMerge, autopilotEnabled, baseBranch, branchStrategy, taskPrefix, goalIds } = req.body;
if (!validateMissionId(missionId)) {
throw badRequest("Invalid mission ID format");
@@ -1199,6 +1217,9 @@ export function createMissionRouter(
if (branchStrategy !== undefined) {
updates.branchStrategy = validateMissionBranchStrategy(branchStrategy);
}
if (taskPrefix !== undefined) {
updates.taskPrefix = validateTaskPrefix(taskPrefix);
}
if (Object.keys(updates).length === 0 && validatedGoalIds === undefined) {
throw badRequest("No valid fields to update");

View File

@@ -60,7 +60,7 @@ describe("worktree-hooks", () => {
expect(hook).toContain("--if-exists doNothing");
expect(hook).toContain("--trailer \"$TRAILER_NAME: $TASK_ID\"");
expect(hook).toContain("--if-exists addIfDifferent");
expect(hook).toContain('CO_AUTHOR_TRAILER="Co-authored-by: Fusion <noreply@runfusion.ai>"');
expect(hook).toContain("CO_AUTHOR_TRAILER='Co-authored-by: Fusion <noreply@runfusion.ai>'");
expect(hook).toContain("s/^FN-//i");
});
@@ -69,7 +69,7 @@ describe("worktree-hooks", () => {
commitAuthorName: "Fusion Bot",
commitAuthorEmail: "bot@example.com",
});
expect(customHook).toContain('CO_AUTHOR_TRAILER="Co-authored-by: Fusion Bot <bot@example.com>"');
expect(customHook).toContain("CO_AUTHOR_TRAILER='Co-authored-by: Fusion Bot <bot@example.com>'");
expect(customHook).toContain("--if-exists addIfDifferent");
const disabledHook = buildCommitMsgTrailerHook("FN-42", { commitAuthorEnabled: false });
@@ -79,11 +79,58 @@ describe("worktree-hooks", () => {
it("parameterizes commit-msg hook for custom prefix and trailer name", () => {
const hook = buildCommitMsgTrailerHook("KB-9", { taskPrefix: "KB", trailerName: "Task-Id" });
expect(hook).toContain('PREFIX="KB"');
expect(hook).toContain('TRAILER_NAME="Task-Id"');
expect(hook).toContain("PREFIX='KB'");
expect(hook).toContain("TRAILER_NAME='Task-Id'");
expect(hook).toContain("s/^KB-//i");
});
it("derives the strip prefix from the task id, ignoring a mismatched options.taskPrefix", () => {
// A per-mission ticket (ERR-5) whose project-wide options.taskPrefix is still "FN" must strip its own ERR- prefix, not "FN-".
const hook = buildCommitMsgTrailerHook("ERR-5", { taskPrefix: "FN" });
expect(hook).toContain("PREFIX='ERR'");
expect(hook).toContain("s/^ERR-//i");
expect(hook).not.toContain("PREFIX='FN'");
});
// FNXC:WorktreeHooks 2026-07-26-12:00: fallback options.taskPrefix is quoted in case and escaped for sed -E so metacharacters cannot break the hook (greptile P2 on PR #1930).
it("quotes the case prefix and escapes sed ERE metacharacters on the fallback taskPrefix path", () => {
const hook = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "A.B+C" });
expect(hook).toContain("PREFIX='A.B+C'");
expect(hook).toContain('"$PREFIX"-*) ;;');
expect(hook).toContain("s/^A\\.B\\+C-//i");
});
// FNXC:WorktreeHooks 2026-07-26-12:00: `/` must be escaped too so `/`-delimited sed stays valid.
it("escapes slash in the fallback taskPrefix so sed delimiters stay intact", () => {
const hook = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "TEAM/API" });
expect(hook).toContain("PREFIX='TEAM/API'");
expect(hook).toContain("s/^TEAM\\/API-//i");
});
// FNXC:WorktreeHooks 2026-07-26-12:00: PREFIX must be a single-quoted shell literal so $(...) cannot expand (greptile P1 security).
it("single-quotes PREFIX so shell command substitution cannot expand on the fallback path", () => {
const cmdSub = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "$(id)" });
expect(cmdSub).toContain("PREFIX='$(id)'");
expect(cmdSub).not.toMatch(/PREFIX="\$\(id\)"/);
expect(cmdSub).not.toMatch(/PREFIX="[^"]*\$\(/);
const injection = buildCommitMsgTrailerHook("not-a-numeric-id", {
taskPrefix: "; rm -rf /; #",
});
expect(injection).toContain("PREFIX='; rm -rf /; #'");
expect(injection).not.toMatch(/PREFIX="[^']*;/);
const withQuote = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "O'Brien" });
// Embedded ' → '\'' inside the outer single-quoted literal.
expect(withQuote).toContain("PREFIX='O'\\''Brien'");
});
it("single-quotes the fallback sed expression so backticks cannot execute", () => {
const hook = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "`id`" });
expect(hook).toContain("PREFIX='`id`'");
expect(hook).toContain('"$PREFIX"-*) ;;');
});
it("installs metadata and pre-commit + commit-msg hooks in linked worktree", async () => {
const root = mkdtempSync(join(tmpdir(), "wt-hook-root-"));
execFileSync("git", ["init"], { cwd: root });

View File

@@ -199,6 +199,22 @@ fi
`;
}
/*
FNXC:WorktreeHooks 2026-07-26-12:00:
options.taskPrefix is a fallback when taskId is not a well-formed "<PREFIX>-<n>" id. It is interpolated into the generated commit-msg hook's case arm and sed -E strip. Escape ERE metacharacters for sed and quote the case pattern via "$PREFIX" so a misconfigured prefix cannot break the hook or strip the wrong id (greptile P2 on PR #1930). Valid UI prefixes remain letter-led alphanumeric; escaping is defense-in-depth for the fallback path.
FNXC:WorktreeHooks 2026-07-26-12:00:
Also escape `/` so a fallback prefix like `TEAM/API` cannot break the `/`-delimited sed substitution. PREFIX= must use a POSIX single-quoted shell literal, not JSON double quotes, so command substitution cannot expand when the commit-msg hook runs under /bin/sh (greptile P1 security on PR #1930).
*/
function escapeSedEre(value: string): string {
return value.replace(/[\\/.^$*+?()[\]{}|]/g, "\\$&");
}
/** POSIX single-quoted shell string literal (no expansion of $, `, \\, etc.). */
function shellSingleQuote(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
export function buildCommitMsgTrailerHook(
taskId: string,
options: {
@@ -209,7 +225,14 @@ export function buildCommitMsgTrailerHook(
commitAuthorEmail?: string;
} = {}
): string {
const taskPrefix = (options.taskPrefix ?? "FN").trim() || "FN";
// Derive the strip prefix from the task id itself so per-mission prefixes
// (e.g. ERR-5 while the project taskPrefix is still "FN") strip correctly.
const trimmedTaskId = taskId.trim();
const derivedPrefix = /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmedTaskId)
? trimmedTaskId.replace(/-\d+$/, "").toUpperCase()
: "";
const taskPrefix = (derivedPrefix || options.taskPrefix || "FN").trim() || "FN";
const taskPrefixSedEre = escapeSedEre(taskPrefix);
const trailerName = (options.trailerName ?? "Fusion-Task-Id").trim() || "Fusion-Task-Id";
const commitAuthorName = (options.commitAuthorName ?? DEFAULT_COMMIT_AUTHOR_NAME).trim() || DEFAULT_COMMIT_AUTHOR_NAME;
const commitAuthorEmail = (options.commitAuthorEmail ?? DEFAULT_COMMIT_AUTHOR_EMAIL).trim() || DEFAULT_COMMIT_AUTHOR_EMAIL;
@@ -219,7 +242,7 @@ export function buildCommitMsgTrailerHook(
# FNXC:CommitAttribution 2026-06-26-12:40:
# Co-author attribution must be deterministic in the worktree hook, not dependent on an AI agent remembering a prompt-supplied git commit -m flag. addIfDifferent keeps an identical agent-added trailer from duplicating while preserving distinct human-provided co-authors.
CO_AUTHOR_TRAILER=${JSON.stringify(`Co-authored-by: ${commitAuthorName} <${commitAuthorEmail}>`)}
CO_AUTHOR_TRAILER=${shellSingleQuote(`Co-authored-by: ${commitAuthorName} <${commitAuthorEmail}>`)}
git interpret-trailers \\
--in-place \\
--if-exists addIfDifferent \\
@@ -236,13 +259,13 @@ TASK_FILE=$(git rev-parse --git-path fusion-task-id)
TASK_ID=$(cat "$TASK_FILE")
[ -n "$TASK_ID" ] || exit 0
PREFIX=${JSON.stringify(taskPrefix)}
PREFIX=${shellSingleQuote(taskPrefix)}
case "$TASK_ID" in
${taskPrefix}-*) ;;
*) TASK_ID="$PREFIX-$(printf '%s' "$TASK_ID" | sed -E "s/^${taskPrefix}-//i")" ;;
"$PREFIX"-*) ;;
*) TASK_ID="$PREFIX-$(printf '%s' "$TASK_ID" | sed -E ${shellSingleQuote(`s/^${taskPrefixSedEre}-//i`)})" ;;
esac
TRAILER_NAME=${JSON.stringify(trailerName)}
TRAILER_NAME=${shellSingleQuote(trailerName)}
git interpret-trailers \
--in-place \