feat(FN-5060): complete Step 5 — wire engine and mission dedup guard

This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 17:36:19 -07:00
committed by gsxdsm
parent 6365fceee4
commit a54496f45d
5 changed files with 154 additions and 34 deletions

View File

@@ -1890,11 +1890,31 @@ describe("MissionStore", () => {
expect(task!.description).toBe("Custom description for the task");
});
it("emits feature:linked event", async () => {
it("links duplicate feature triage calls to the same canonical task", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const featureA = msWithTs.addFeature(slice.id, { title: "Feature A" });
const featureB = msWithTs.addFeature(slice.id, { title: "Feature B" });
const first = await msWithTs.triageFeature(featureA.id, "Same Task", "Same deterministic description");
const second = await msWithTs.triageFeature(featureB.id, "Same Task", "Same deterministic description");
expect(first.taskId).toBeTruthy();
expect(second.taskId).toBe(first.taskId);
const tasks = await ts.listTasks({ slim: true });
expect(tasks).toHaveLength(1);
});
it("emits feature:linked event", async () => { const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
msWithTs.on("feature:linked", linkedHandler);

View File

@@ -53,6 +53,7 @@ import {
validateSnapshotEnvelope,
type MissionHierarchySnapshot,
} from "./shared-mesh-state.js";
import { reconcileDeterministicDuplicate, runDeterministicDuplicateGuard } from "./duplicate-guard.js";
// ── Constants ────────────────────────────────────────────────────────
/**
@@ -3127,26 +3128,52 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const milestone = slice ? this.getMilestone(slice.milestoneId) : undefined;
const missionId = milestone?.missionId;
// Create the task
const task = await this.taskStore.createTask({
const lockScope = missionId ? `mission:${missionId}` : `mission-store:${this.db.dbPath}`;
const guard = await runDeterministicDuplicateGuard(this.taskStore, {
title: taskTitle || feature.title,
description,
branch: branchOptions?.branch,
baseBranch: branchOptions?.baseBranch,
...(missionId
? {
branchContext: {
groupId: `mission:${missionId}`,
source: "mission" as const,
assignmentMode: branchOptions?.assignmentMode ?? "shared",
inheritedBaseBranch: branchOptions?.baseBranch,
},
}
: {}),
});
}, { lockScope });
// Link the feature to the new task (this also updates feature status to "triaged")
const updated = this.linkFeatureToTask(featureId, task.id);
let linkedTaskId: string;
try {
if (guard.action === "duplicate" && guard.existing) {
linkedTaskId = guard.existing.id;
} else {
const createdTask = await this.taskStore.createTask({
title: taskTitle || feature.title,
description,
branch: branchOptions?.branch,
baseBranch: branchOptions?.baseBranch,
...(missionId
? {
branchContext: {
groupId: `mission:${missionId}`,
source: "mission" as const,
assignmentMode: branchOptions?.assignmentMode ?? "shared",
inheritedBaseBranch: branchOptions?.baseBranch,
},
}
: {}),
});
if (guard.fingerprint) {
await this.taskStore.updateTask(createdTask.id, {
sourceMetadataPatch: { contentFingerprint: guard.fingerprint },
});
}
const reconcile = await reconcileDeterministicDuplicate(this.taskStore, {
createdTask,
fingerprint: guard.fingerprint,
});
linkedTaskId = reconcile.canonical.id;
}
} finally {
guard.releaseLock();
}
// Link the feature to the task (this also updates feature status to "triaged")
const updated = this.linkFeatureToTask(featureId, linkedTaskId);
return updated;
}