feat(FN-4053): unify task ID allocation with store-owned distributed alloca

Unifies task ID allocation under a single store-owned distributed allocator, removing redundant ID-generation logic from dashboard route handlers and simplifying the overall flow; Step 1 merges the allocation authority into the store, Step 3 removes the now-unnecessary mixed-path routing, and tests

Fusion-Task-Id: FN-4053
This commit is contained in:
Fusion
2026-05-11 21:38:25 -07:00
committed by gsxdsm
parent 8f7f728dd6
commit 287ebaf102
12 changed files with 244 additions and 359 deletions

View File

@@ -562,28 +562,20 @@ describe("TaskStore", () => {
}
});
it("logs allocateId disk sync failures while preserving task creation", async () => {
it("creates tasks through distributed allocation without config.json sync dependency", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const storeAny = store as any;
const originalConfigPath = storeAny.configPath;
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
try {
const task = await store.createTask({ description: "allocate despite sync failure" });
const task = await store.createTask({ description: "allocate without config sync" });
expect(task.id).toBe("FN-001");
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after ID allocation"),
(call) => typeof call[0] === "string" && call[0].includes("after ID allocation"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "allocateId:disk-sync",
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
taskId: task.id,
});
expect(typeof context.error).toBe("string");
expect(warningCall).toBeUndefined();
} finally {
storeAny.configPath = originalConfigPath;
warnSpy.mockRestore();

View File

@@ -707,6 +707,53 @@ describe("TaskStore", () => {
describe("distributed task-id allocator seam", () => {
it("commits allocator reservations for createTask, duplicateTask, and refineTask", async () => {
const created = await store.createTask({ description: "created with allocator" });
const duplicate = await store.duplicateTask(created.id);
await store.moveTask(created.id, "todo");
await store.moveTask(created.id, "in-progress");
await store.moveTask(created.id, "in-review");
await store.moveTask(created.id, "done");
const refined = await store.refineTask(created.id, "refine this");
const reservationRows = store
.getDatabase()
.prepare("SELECT taskId, status FROM distributed_task_id_reservations WHERE taskId IN (?, ?, ?) ORDER BY taskId")
.all(created.id, duplicate.id, refined.id) as Array<{ taskId: string; status: string }>;
expect(reservationRows).toEqual([
{ taskId: created.id, status: "committed" },
{ taskId: duplicate.id, status: "committed" },
{ taskId: refined.id, status: "committed" },
]);
});
it("keeps IDs collision-free across mixed store and direct reservation creates", async () => {
// Regression for FN-4053: before unifying local allocation, store.createTask()
// used config.nextId while direct distributed reservations advanced
// distributed_task_id_state. Interleaving both paths could reuse IDs.
const first = await store.createTask({ description: "first via store" });
const allocator = store.getDistributedTaskIdAllocator();
const reservation = await allocator.reserveDistributedTaskId({ prefix: "FN", nodeId: "node-a" });
const second = await store.createTaskWithReservedId(
{ description: "second via direct reservation" },
{ taskId: reservation.taskId },
);
await allocator.commitDistributedTaskIdReservation({
reservationId: reservation.reservationId,
nodeId: "node-a",
});
const third = await store.createTask({ description: "third via store" });
expect(first.id).toBe("FN-001");
expect(second.id).toBe("FN-002");
expect(third.id).toBe("FN-003");
expect(third.id).not.toBe(second.id);
});
it("returns a stable allocator instance", () => {
const first = store.getDistributedTaskIdAllocator();
const second = store.getDistributedTaskIdAllocator();

View File

@@ -134,11 +134,10 @@ describe("TaskStore", () => {
const sortedIds = [...ids].sort();
expect(sortedIds).toEqual(["FN-001", "FN-002", "FN-003", "FN-004", "FN-005"]);
// config.json should be valid JSON with nextId = 6
// config.json should still be valid JSON after concurrent task creation
const configPath = join(rootDir, ".fusion", "config.json");
const raw = await readFile(configPath, "utf-8");
const config = JSON.parse(raw);
expect(config.nextId).toBe(6);
expect(() => JSON.parse(raw)).not.toThrow();
// No .tmp files left behind
const haiDir = join(rootDir, ".fusion");

View File

@@ -2054,7 +2054,7 @@ describe("TaskStore", () => {
expect(task.id).toBe("PROJ-001");
});
it("prefix change mid-stream continues sequence", async () => {
it("prefix change mid-stream starts a fresh per-prefix sequence", async () => {
const t1 = await harness.store().createTask({ description: "First" });
const t2 = await harness.store().createTask({ description: "Second" });
expect(t1.id).toBe("FN-001");
@@ -2062,7 +2062,7 @@ describe("TaskStore", () => {
await harness.store().updateSettings({ taskPrefix: "PROJ" });
const t3 = await harness.store().createTask({ description: "Third" });
expect(t3.id).toBe("PROJ-003");
expect(t3.id).toBe("PROJ-001");
});
it("listTasks returns tasks regardless of prefix", async () => {
@@ -2072,7 +2072,7 @@ describe("TaskStore", () => {
const tasks = await harness.store().listTasks();
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.id).sort()).toEqual(["FN-001", "PROJ-002"]);
expect(tasks.map((t) => t.id).sort()).toEqual(["FN-001", "PROJ-001"]);
});
it("supports pagination with limit and offset", async () => {