feat(FN-4044): harden task ID collision guards and allocation safety

Hardens task ID collision guards across distributed instances by adding `isAllocated` checks in `store.ts` and rewriting `distributed-task-id.ts` with deterministic collision-safe allocation logic, backed by a new `store-create-collision.test.ts` suite reproducing the race path. Updates architecture

Fusion-Task-Id: FN-4044
This commit is contained in:
Fusion
2026-05-12 06:26:37 -07:00
committed by gsxdsm
parent 2be89d15fd
commit bec0b34c45
11 changed files with 632 additions and 194 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Harden task creation so stale allocator state or colliding reservations fail safely instead of overwriting an existing task row or task directory.

View File

@@ -674,12 +674,15 @@ A lease is recoverable only when there is **no active local executor session for
- Reserve/commit/abort execute under a process-local lock and a single SQLite transaction. Lazy reservation expiry cleanup runs inside those same transactions. - Reserve/commit/abort execute under a process-local lock and a single SQLite transaction. Lazy reservation expiry cleanup runs inside those same transactions.
- Default reservation TTL is `15 * 60 * 1000` ms (15 minutes). Expired/aborted reservations are **burned IDs** and are never reissued. - Default reservation TTL is `15 * 60 * 1000` ms (15 minutes). Expired/aborted reservations are **burned IDs** and are never reissued.
- `committedClusterTaskCount` from allocator state is the only authoritative cluster-wide committed-task count. Local task-row counts and ID suffix math are not authoritative. - `committedClusterTaskCount` from allocator state is the only authoritative cluster-wide committed-task count. Local task-row counts and ID suffix math are not authoritative.
- Store open reconciles every known prefix in `distributed_task_id_state` to `max(current nextSequence, max(tasks suffix)+1, max(archivedTasks suffix)+1, max(reservation sequence)+1)`. This self-heals stale counters before ordinary task creation resumes.
- Mesh allocator write routes (`/api/mesh/task-ids/reserve|commit|abort`) return `503` when the coordinator node is unreachable; they never fall back to local-only cluster ID issuance. - Mesh allocator write routes (`/api/mesh/task-ids/reserve|commit|abort`) return `503` when the coordinator node is unreachable; they never fall back to local-only cluster ID issuance.
- Cluster task creation now uses a strong-write reserve → create → replicate → commit/abort sequence. - Cluster task creation now uses a strong-write reserve → create → replicate → commit/abort sequence.
- Ordinary local task creation (`TaskStore.createTask()`, duplicate, and refine flows) now allocates IDs through the same distributed reserve/commit/abort lifecycle owned by `TaskStore`. - Ordinary local task creation (`TaskStore.createTask()`, duplicate, and refine flows) now allocates IDs through the same distributed reserve/commit/abort lifecycle owned by `TaskStore`.
- `POST /api/tasks` uses the store-owned allocator path for local creates rather than maintaining a separate route-local allocator implementation. - `POST /api/tasks` uses the store-owned allocator path for local creates rather than maintaining a separate route-local allocator implementation.
- `POST /api/tasks` reserves a distributed ID, creates the authoritative local task with that reserved ID, then POSTs authenticated replication payloads to peer nodes. - `POST /api/tasks` reserves a distributed ID, creates the authoritative local task with that reserved ID, then POSTs authenticated replication payloads to peer nodes.
- Creation self-heals stale ID overlap state: if a reserved `FN-*` collides with an existing task (`Task ID already exists...` or replicated-create collision), the route aborts that reservation, cleans up partial local state, reserves the next ID, and retries up to a bounded limit. - All create-class writes now use conflict-raising inserts, not SQLite `ON CONFLICT ... DO UPDATE`. Existing task rows and `.fusion/tasks/{id}` contents always win over stale counters or colliding reservations.
- Local create paths perform a final active+archived existence check immediately before insert. If a reserved `FN-*` still collides, the reservation is aborted/burned and the create fails loudly instead of rewriting the existing task.
- Creation self-heals stale overlap state at the route layer: if a reserved `FN-*` collides with an existing task (`Task ID already exists...` or replicated-create collision), the route aborts that reservation, cleans up partial local state, reserves the next ID, and retries up to a bounded limit.
- Replica apply uses `TaskStore.applyReplicatedTaskCreate(...)`, which is idempotent by task ID: replaying the same payload returns the existing task without creating duplicates. - Replica apply uses `TaskStore.applyReplicatedTaskCreate(...)`, which is idempotent by task ID: replaying the same payload returns the existing task without creating duplicates.
- If an incoming replicated payload conflicts with a different existing task record for the same ID, the apply path returns a deterministic collision error instead of overwriting data. - If an incoming replicated payload conflicts with a different existing task record for the same ID, the apply path returns a deterministic collision error instead of overwriting data.
- Any replication/coordinator failure aborts the reservation and returns write failure (`503`), so this path does not report success for local-only partial writes. - Any replication/coordinator failure aborts the reservation and returns write failure (`503`), so this path does not report success for local-only partial writes.

View File

@@ -4,8 +4,9 @@
- `distributed_task_id_state` is the authoritative local task-ID allocator state. `nextSequence` is the active high-water mark used for local ID reservations. - `distributed_task_id_state` is the authoritative local task-ID allocator state. `nextSequence` is the active high-water mark used for local ID reservations.
- `distributed_task_id_reservations` tracks reserve/commit/abort lifecycle entries. Aborted/expired reservations are burned and never reissued. - `distributed_task_id_reservations` tracks reserve/commit/abort lifecycle entries. Aborted/expired reservations are burned and never reissued.
- `config.nextId` is retained only as a legacy compatibility field and optional seed source; runtime task creation no longer mutates it as allocator truth. - `config.nextId` is retained only as a deprecated legacy compatibility field and optional one-time seed source. Fusion still reads it during reconciliation, but runtime task creation and settings writes no longer mutate it.
- Startup allocator reconciliation bumps each active prefix sequence to `max(current nextSequence, max(existing task suffix)+1)` across live + archived tasks to self-heal stale allocator drift. - Startup/store-open allocator reconciliation bumps each active prefix sequence to `max(current nextSequence, max(tasks suffix)+1, max(archivedTasks suffix)+1, max(reservation sequence)+1)` so stale allocator rows self-heal before local task creation resumes.
- Create-class task persistence is intentionally non-destructive: new tasks use plain `INSERT` semantics, while `ON CONFLICT(id) DO UPDATE` remains update-only. If counters drift and a reserved ID still collides, the create fails and the existing SQLite row / task directory stays intact.
## SQLite write-path lock recovery (FN-4042 / FN-4083) ## SQLite write-path lock recovery (FN-4042 / FN-4083)

View File

@@ -369,6 +369,26 @@ Archive entries preserve key metadata needed for restoration, including:
- Moves task to `done` - Moves task to `done`
- Logs “Task restored from archive” when recovering from compact archive entry - Logs “Task restored from archive” when recovering from compact archive entry
### Task-ID collision safety and operator recovery
- Ordinary task creation, duplicate, and refine flows now fail safely if the chosen task ID already exists in active storage or archive storage. Existing task rows/files always win; the new create attempt must retry with a fresh reservation instead of overwriting data.
- A failed create may burn a distributed reservation. Gaps in `FN-*` numbering are expected and are safer than reissuing a possibly-colliding ID.
- `config.nextId` is legacy/read-only. The live allocator state is `distributed_task_id_state.nextSequence`, reconciled on store open against live tasks, archived task snapshots, and reservation history.
If you suspect **historical overwrites from pre-FN-4044 builds**, inspect surviving evidence in this order:
1. `archive.db` / archived task snapshots for the missing ID
2. `.fusion/tasks/<id>/task.json.bak`, `PROMPT.md`, attachments, and any surviving worktree branch named for the task
3. agent run logs / task documents / activity log entries that still mention the original ID
4. git commits whose subject/body references the original task ID but no longer matches the current task metadata
Recovery/backfill guidance:
- If the original task row still exists in archive storage, unarchive or manually recreate the task from that snapshot.
- If only prompt/worktree/git evidence survives, create a replacement task with a new ID and copy over the recovered description, prompt, documents, and attachments manually.
- If both the active row and archive snapshot were overwritten, Fusion cannot reconstruct lost attachments/comments automatically; recreate them from git history, branch/worktree contents, screenshots, or external issue trackers.
- Record the incident in the replacement task so future audits understand why the task ID and commit history diverge.
## GitHub Issue Import and PR Creation ## GitHub Issue Import and PR Creation
Import issues: Import issues:

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Database } from "../db.js"; import { Database } from "../db.js";
import { createDistributedTaskIdAllocator, DistributedTaskIdError } from "../distributed-task-id.js"; import { createDistributedTaskIdAllocator, DistributedTaskIdError, reconcileTaskIdState } from "../distributed-task-id.js";
describe("distributed-task-id allocator", () => { describe("distributed-task-id allocator", () => {
const createAllocator = () => { const createAllocator = () => {
@@ -84,6 +84,33 @@ describe("distributed-task-id allocator", () => {
expect(state.nextSequence).toBe(3702); expect(state.nextSequence).toBe(3702);
}); });
it("reconciles stale state rows past live tasks, archived tasks, and reservations", () => {
const db = new Database("/tmp/fusion-test", { inMemory: true });
db.init();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
).run("FN-003", now, now);
db.prepare(
"INSERT INTO archivedTasks (id, data, archivedAt) VALUES (?, ?, ?)",
).run("FN-005", JSON.stringify({ id: "FN-005" }), now);
db.prepare(
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("FN", 2, 1, "FN-001", now);
db.prepare(
`INSERT INTO distributed_task_id_reservations (
reservationId, prefix, nodeId, sequence, taskId, status, reason, expiresAt, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)`,
).run("res-7", "FN", "node-a", 7, "FN-007", new Date(Date.now() + 60_000).toISOString(), now, now);
const reconciled = reconcileTaskIdState(db);
expect(reconciled).toContain("FN");
const state = db.prepare("SELECT nextSequence FROM distributed_task_id_state WHERE prefix = ?").get("FN") as { nextSequence: number };
expect(state.nextSequence).toBe(8);
});
it("skips stale overlapping nextSequence values and reserves the next free id", async () => { it("skips stale overlapping nextSequence values and reserves the next free id", async () => {
const db = new Database("/tmp/fusion-test", { inMemory: true }); const db = new Database("/tmp/fusion-test", { inMemory: true });
db.init(); db.init();
@@ -106,6 +133,26 @@ describe("distributed-task-id allocator", () => {
expect(state.committedClusterTaskCount).toBe(1); expect(state.committedClusterTaskCount).toBe(1);
}); });
it("reconciles stale reservation sequences before allocating a new reservation", async () => {
const db = new Database("/tmp/fusion-test", { inMemory: true });
db.init();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("FN", 2, 1, "FN-001", now);
db.prepare(
`INSERT INTO distributed_task_id_reservations (
reservationId, prefix, nodeId, sequence, taskId, status, reason, expiresAt, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)`,
).run("res-2", "FN", "node-a", 2, "FN-002", new Date(Date.now() + 60_000).toISOString(), now, now);
const allocator = createDistributedTaskIdAllocator(db);
const reservation = await allocator.reserveDistributedTaskId({ prefix: "FN", nodeId: "node-b" });
expect(reservation.taskId).toBe("FN-003");
expect(reservation.sequence).toBe(3);
});
it("state reports committed count independently from nextSequence", async () => { it("state reports committed count independently from nextSequence", async () => {
const { allocator } = createAllocator(); const { allocator } = createAllocator();
const first = await allocator.reserveDistributedTaskId({ prefix: "FN", nodeId: "node-a" }); const first = await allocator.reserveDistributedTaskId({ prefix: "FN", nodeId: "node-a" });

View File

@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore collision guards", () => {
const harness = createTaskStoreTestHarness();
let store = harness.store();
let rootDir = harness.rootDir();
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
rootDir = harness.rootDir();
});
afterEach(async () => {
await harness.afterEach();
vi.restoreAllMocks();
});
const forceAllocatorCollision = (taskId: string) => {
const allocator = store.getDistributedTaskIdAllocator();
vi.spyOn(allocator, "reserveDistributedTaskId").mockResolvedValue({
reservationId: `res-${taskId}`,
taskId,
sequence: Number.parseInt(taskId.split("-")[1] ?? "0", 10),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
committedClusterTaskCount: 0,
});
vi.spyOn(allocator, "commitDistributedTaskIdReservation").mockResolvedValue({
reservationId: `res-${taskId}`,
taskId,
sequence: Number.parseInt(taskId.split("-")[1] ?? "0", 10),
committedAt: new Date().toISOString(),
committedClusterTaskCount: 1,
});
vi.spyOn(allocator, "abortDistributedTaskIdReservation").mockResolvedValue({
reservationId: `res-${taskId}`,
taskId,
sequence: Number.parseInt(taskId.split("-")[1] ?? "0", 10),
abortedAt: new Date().toISOString(),
committedClusterTaskCount: 0,
reason: "failed-create",
});
};
it("createTask throws and preserves the existing task when the allocator returns a colliding id", async () => {
const original = await store.createTask({ title: "Original", description: "original task", column: "todo" });
const originalPromptPath = join(rootDir, ".fusion", "tasks", original.id, "PROMPT.md");
const originalPrompt = await readFile(originalPromptPath, "utf8");
forceAllocatorCollision(original.id);
await expect(
store.createTask({ title: "Replacement", description: "replacement task", column: "todo" }),
).rejects.toThrow(`Task ID already exists: ${original.id}`);
const persisted = await store.getTask(original.id);
const promptAfter = await readFile(originalPromptPath, "utf8");
expect(persisted.title).toBe("Original");
expect(persisted.description).toBe("original task");
expect(promptAfter).toBe(originalPrompt);
});
it("duplicateTask throws and preserves the unrelated task when its reserved id collides", async () => {
const source = await store.createTask({ title: "Source", description: "source task" });
const victim = await store.createTask({ title: "Victim", description: "victim task", column: "todo" });
forceAllocatorCollision(victim.id);
await expect(store.duplicateTask(source.id)).rejects.toThrow(`Task ID already exists: ${victim.id}`);
const persisted = await store.getTask(victim.id);
expect(persisted.title).toBe("Victim");
expect(persisted.description).toBe("victim task");
expect(persisted.sourceParentTaskId).toBeUndefined();
});
it("refineTask throws and preserves the unrelated task when its reserved id collides", async () => {
const source = await store.createTask({ title: "Source", description: "source task", column: "todo" });
await store.moveTask(source.id, "in-progress");
await store.moveTask(source.id, "in-review");
await store.moveTask(source.id, "done");
const victim = await store.createTask({ title: "Victim", description: "victim task", column: "todo" });
forceAllocatorCollision(victim.id);
await expect(store.refineTask(source.id, "apply polish")).rejects.toThrow(`Task ID already exists: ${victim.id}`);
const persisted = await store.getTask(victim.id);
expect(persisted.title).toBe("Victim");
expect(persisted.description).toBe("victim task");
expect(persisted.dependencies).toEqual([]);
});
it("createTask rejects archived-id collisions from stale distributed_task_id_state without overwriting archive data", async () => {
const archived = await store.createTask({ title: "Archived", description: "archived task", column: "todo" });
await store.moveTask(archived.id, "in-progress");
await store.moveTask(archived.id, "in-review");
await store.moveTask(archived.id, "done");
const archivedDetail = await store.getTask(archived.id);
await store.archiveTask(archived.id);
const archivedPrefix = archived.id.split("-")[0];
store.getDatabase().prepare("DELETE FROM distributed_task_id_reservations WHERE prefix = ?").run(archivedPrefix);
store.getDatabase().prepare("UPDATE distributed_task_id_state SET nextSequence = 1 WHERE prefix = ?").run(archivedPrefix);
await expect(store.createTask({ title: "New", description: "new task" })).rejects.toThrow(
`Task ID already exists: ${archived.id}`,
);
const preservedArchive = await store.getTask(archived.id);
expect(preservedArchive.title).toBe(archivedDetail.title);
expect(preservedArchive.description).toBe(archivedDetail.description);
expect(preservedArchive.prompt).toBe(archivedDetail.prompt);
});
});

View File

@@ -109,35 +109,35 @@ describe("TaskStore", () => {
}); });
}); });
describe("FTS5 corruption recovery during upsert", () => { describe("FTS5 corruption recovery during create inserts", () => {
it("rebuilds FTS5 and retries once when upsert fails with an FTS corruption error", async () => { it("rebuilds FTS5 and retries once when an insert fails with an FTS corruption error", async () => {
const db = harness.store().getDatabase(); const db = harness.store().getDatabase();
const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true); const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true);
const upsertSpy = vi.spyOn(harness.store() as any, "upsertTask"); const insertSpy = vi.spyOn(harness.store() as any, "insertTask");
const originalUpsert = upsertSpy.getMockImplementation(); const originalInsert = insertSpy.getMockImplementation();
upsertSpy insertSpy
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
throw new Error("SQLITE_CORRUPT: corruption found reading blob in fts5"); throw new Error("SQLITE_CORRUPT: corruption found reading blob in fts5");
}) })
.mockImplementation((task: any) => { .mockImplementation((task: any) => {
if (originalUpsert) { if (originalInsert) {
return originalUpsert(task); return originalInsert(task);
} }
return (Object.getPrototypeOf(harness.store()) as any).upsertTask.call(harness.store(), task); return (Object.getPrototypeOf(harness.store()) as any).insertTask.call(harness.store(), task);
}); });
const created = await harness.store().createTask({ description: "Recover from FTS corruption" }); const created = await harness.store().createTask({ description: "Recover from FTS corruption" });
expect(created.id).toBeDefined(); expect(created.id).toBeDefined();
expect(rebuildSpy).toHaveBeenCalledTimes(1); expect(rebuildSpy).toHaveBeenCalledTimes(1);
expect(upsertSpy).toHaveBeenCalledTimes(2); expect(insertSpy).toHaveBeenCalledTimes(2);
}); });
it("propagates non-FTS errors without rebuild", async () => { it("propagates non-FTS errors without rebuild", async () => {
const db = harness.store().getDatabase(); const db = harness.store().getDatabase();
const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true); const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true);
vi.spyOn(harness.store() as any, "upsertTask").mockImplementationOnce(() => { vi.spyOn(harness.store() as any, "insertTask").mockImplementationOnce(() => {
throw new Error("constraint failed"); throw new Error("constraint failed");
}); });

View File

@@ -42,7 +42,7 @@ describe("test-project fixture", () => {
const configRaw = await readFile(join(fixture.rootDir, ".fusion", "config.json"), "utf-8"); const configRaw = await readFile(join(fixture.rootDir, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw); const config = JSON.parse(configRaw);
expect(config.nextId).toBe(1); expect(config.nextId).toBeUndefined();
expect(config.settings.taskPrefix).toBe("FN"); expect(config.settings.taskPrefix).toBe("FN");
const tasks = await fixture.store.listTasks(); const tasks = await fixture.store.listTasks();

View File

@@ -263,6 +263,9 @@ CREATE TABLE IF NOT EXISTS tasks (
); );
-- Config table (single row with project settings) -- Config table (single row with project settings)
-- nextId is a deprecated legacy allocator counter retained read-only for one
-- release so older databases/config consumers can still load it during the
-- distributed_task_id_state transition.
CREATE TABLE IF NOT EXISTS config ( CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1, nextId INTEGER DEFAULT 1,

View File

@@ -12,6 +12,7 @@ import type {
} from "./types.js"; } from "./types.js";
const DEFAULT_RESERVATION_TTL_MS = 15 * 60 * 1000; const DEFAULT_RESERVATION_TTL_MS = 15 * 60 * 1000;
const TASK_ID_PATTERN = /^([A-Z][A-Z0-9]*)-(\d+)$/;
export interface DistributedTaskIdAllocator { export interface DistributedTaskIdAllocator {
formatDistributedTaskId(prefix: string, sequence: number): string; formatDistributedTaskId(prefix: string, sequence: number): string;
@@ -56,6 +57,164 @@ type ReservationRow = {
abortedAt: string | null; abortedAt: string | null;
}; };
function parseTaskId(taskId: string): { prefix: string; sequence: number } | null {
const match = taskId.trim().toUpperCase().match(TASK_ID_PATTERN);
if (!match) {
return null;
}
const sequence = Number.parseInt(match[2], 10);
if (!Number.isFinite(sequence)) {
return null;
}
return { prefix: match[1], sequence };
}
function getConfiguredPrefixAndLegacyNextId(db: Database): { prefix: string; nextId: number | null } {
try {
const row = db
.prepare("SELECT nextId, settings FROM config WHERE id = 1")
.get() as { nextId: number | null; settings: string | null } | undefined;
if (!row) {
return { prefix: "KB", nextId: null };
}
const settings = row.settings ? (JSON.parse(row.settings) as { taskPrefix?: string }) : null;
return {
prefix: (settings?.taskPrefix ?? "KB").trim().toUpperCase(),
nextId: typeof row.nextId === "number" ? row.nextId : null,
};
} catch {
return { prefix: "KB", nextId: null };
}
}
function getKnownPrefixes(db: Database): Set<string> {
const prefixes = new Set<string>();
const configured = getConfiguredPrefixAndLegacyNextId(db).prefix;
if (configured) {
prefixes.add(configured);
}
const addFromQuery = (sql: string, mapper: (row: Record<string, unknown>) => string | undefined): void => {
try {
const rows = db.prepare(sql).all() as Array<Record<string, unknown>>;
for (const row of rows) {
const prefix = mapper(row)?.trim().toUpperCase();
if (prefix) {
prefixes.add(prefix);
}
}
} catch {
// Best-effort for tests / partial schemas.
}
};
addFromQuery("SELECT prefix FROM distributed_task_id_state", (row) => row.prefix as string | undefined);
addFromQuery("SELECT prefix FROM distributed_task_id_reservations", (row) => row.prefix as string | undefined);
addFromQuery("SELECT id FROM tasks", (row) => parseTaskId(String(row.id ?? ""))?.prefix);
addFromQuery("SELECT id FROM archivedTasks", (row) => parseTaskId(String(row.id ?? ""))?.prefix);
return prefixes;
}
function getMaxTaskSequenceFromTable(db: Database, table: string, prefix: string): number {
try {
const rows = db.prepare(`SELECT id FROM ${table} WHERE id LIKE ?`).all(`${prefix}-%`) as Array<{ id: string }>;
let maxSequence = 0;
for (const row of rows) {
const parsed = parseTaskId(row.id);
if (parsed?.prefix === prefix && parsed.sequence > maxSequence) {
maxSequence = parsed.sequence;
}
}
return maxSequence;
} catch {
return 0;
}
}
function getMaxReservationSequence(db: Database, prefix: string): number {
try {
const row = db
.prepare("SELECT MAX(sequence) AS maxSeq FROM distributed_task_id_reservations WHERE prefix = ?")
.get(prefix) as { maxSeq: number | null } | undefined;
return typeof row?.maxSeq === "number" ? row.maxSeq : 0;
} catch {
return 0;
}
}
function getNextSequenceFloor(db: Database, prefix: string): number {
const configured = getConfiguredPrefixAndLegacyNextId(db);
let nextSequence = 1;
if (configured.prefix === prefix && configured.nextId && configured.nextId > nextSequence) {
nextSequence = configured.nextId;
}
const taskHighWaterMark = getMaxTaskSequenceFromTable(db, "tasks", prefix) + 1;
const archivedHighWaterMark = getMaxTaskSequenceFromTable(db, "archivedTasks", prefix) + 1;
const reservationHighWaterMark = getMaxReservationSequence(db, prefix) + 1;
nextSequence = Math.max(nextSequence, taskHighWaterMark, archivedHighWaterMark, reservationHighWaterMark);
return nextSequence;
}
function ensureStateRow(db: Database, prefix: string): void {
const nowIso = new Date().toISOString();
const nextSequence = getNextSequenceFloor(db, prefix);
db.prepare(
`INSERT OR IGNORE INTO distributed_task_id_state (
prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt
) VALUES (?, ?, 0, NULL, ?)`,
).run(prefix, nextSequence, nowIso);
db.prepare(
`UPDATE distributed_task_id_state
SET nextSequence = MAX(nextSequence, ?),
updatedAt = ?
WHERE prefix = ?`,
).run(nextSequence, nowIso, prefix);
}
export function reconcileTaskIdState(db: Database): string[] {
const nowIso = new Date().toISOString();
return db.transaction(() => {
const reconciled: string[] = [];
for (const prefix of getKnownPrefixes(db)) {
const nextSequence = getNextSequenceFloor(db, prefix);
db.prepare(
`INSERT OR IGNORE INTO distributed_task_id_state (
prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt
) VALUES (?, ?, 0, NULL, ?)`,
).run(prefix, nextSequence, nowIso);
const before = db
.prepare("SELECT nextSequence FROM distributed_task_id_state WHERE prefix = ?")
.get(prefix) as { nextSequence: number } | undefined;
db.prepare(
`UPDATE distributed_task_id_state
SET nextSequence = MAX(nextSequence, ?),
updatedAt = ?
WHERE prefix = ?`,
).run(nextSequence, nowIso, prefix);
const after = db
.prepare("SELECT nextSequence FROM distributed_task_id_state WHERE prefix = ?")
.get(prefix) as { nextSequence: number } | undefined;
if (!before || !after || after.nextSequence !== before.nextSequence) {
reconciled.push(prefix);
}
}
if (reconciled.length > 0) {
db.bumpLastModified();
}
return reconciled;
});
}
export function formatDistributedTaskId(prefix: string, sequence: number): string { export function formatDistributedTaskId(prefix: string, sequence: number): string {
const normalizedPrefix = prefix.trim().toUpperCase(); const normalizedPrefix = prefix.trim().toUpperCase();
if (!normalizedPrefix) { if (!normalizedPrefix) {
@@ -105,70 +264,6 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
return existsInTable("tasks") || existsInTable("archivedTasks"); return existsInTable("tasks") || existsInTable("archivedTasks");
}; };
const ensureStateRow = (prefix: string): void => {
// Seed nextSequence past any pre-existing task ID for this prefix. Without
// this, projects whose tasks were originally allocated through
// TaskStore.allocateId() (config.nextId) would have mesh-routed task
// creates restart at 1 and collide with historical FN-001 / FN-002 / …
// IDs (regression introduced when the dashboard task-create route was
// wired to reserveDistributedTaskId in FN-3450).
//
// We take the max of:
// - 1 (historical default)
// - the legacy config.nextId counter, when the configured taskPrefix
// matches `prefix`
// - one past the highest numeric suffix on any existing task for this
// prefix (live tasks + archived), to handle DBs where config.nextId
// ever drifted below the real high-water mark
let seedSequence = 1;
try {
const configRow = db
.prepare("SELECT nextId, settings FROM config WHERE id = 1")
.get() as { nextId: number | null; settings: string | null } | undefined;
if (configRow) {
const settings = configRow.settings ? (JSON.parse(configRow.settings) as { taskPrefix?: string }) : null;
const configuredPrefix = (settings?.taskPrefix ?? "KB").trim().toUpperCase();
if (configuredPrefix === prefix && typeof configRow.nextId === "number" && configRow.nextId > seedSequence) {
seedSequence = configRow.nextId;
}
}
} catch {
// Best-effort: if the config row/column is missing (fresh test DB) we
// fall back to the historical default of 1.
}
const idPattern = `${prefix}-%`;
const probeTable = (table: string): void => {
try {
const row = db
.prepare(
`SELECT MAX(CAST(substr(id, ${prefix.length + 2}) AS INTEGER)) AS maxSeq
FROM ${table}
WHERE id LIKE ? AND substr(id, ${prefix.length + 2}) GLOB '[0-9]*'`,
)
.get(idPattern) as { maxSeq: number | null } | undefined;
if (row && typeof row.maxSeq === "number" && row.maxSeq + 1 > seedSequence) {
seedSequence = row.maxSeq + 1;
}
} catch {
// Table may not exist (tests, isolated DBs); ignore.
}
};
probeTable("tasks");
probeTable("archivedTasks");
const nowIso = new Date().toISOString();
db.prepare(
`INSERT OR IGNORE INTO distributed_task_id_state (
prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt
) VALUES (?, ?, 0, NULL, ?)`
).run(prefix, seedSequence, nowIso);
db.prepare(
`UPDATE distributed_task_id_state
SET nextSequence = MAX(nextSequence, ?),
updatedAt = ?
WHERE prefix = ?`
).run(seedSequence, nowIso, prefix);
};
return { return {
formatDistributedTaskId, formatDistributedTaskId,
reserveDistributedTaskId: async (input) => reserveDistributedTaskId: async (input) =>
@@ -184,7 +279,7 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
if (!prefix) { if (!prefix) {
throw new DistributedTaskIdError("prefix is required", "invalid_prefix"); throw new DistributedTaskIdError("prefix is required", "invalid_prefix");
} }
ensureStateRow(prefix); ensureStateRow(db, prefix);
const state = db const state = db
.prepare( .prepare(
@@ -203,7 +298,7 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
db.prepare( db.prepare(
`INSERT INTO distributed_task_id_reservations ( `INSERT INTO distributed_task_id_reservations (
reservationId, prefix, nodeId, sequence, taskId, status, reason, expiresAt, createdAt, updatedAt reservationId, prefix, nodeId, sequence, taskId, status, reason, expiresAt, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)`,
).run(reservationId, prefix, input.nodeId, sequence, taskId, expiresAt, nowIso, nowIso); ).run(reservationId, prefix, input.nodeId, sequence, taskId, expiresAt, nowIso, nowIso);
db.prepare( db.prepare(
@@ -252,7 +347,7 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
WHERE reservationId = ?`, WHERE reservationId = ?`,
).run(nowIso, nowIso, row.reservationId); ).run(nowIso, nowIso, row.reservationId);
ensureStateRow(row.prefix); ensureStateRow(db, row.prefix);
db.prepare( db.prepare(
`UPDATE distributed_task_id_state `UPDATE distributed_task_id_state
SET committedClusterTaskCount = committedClusterTaskCount + 1, SET committedClusterTaskCount = committedClusterTaskCount + 1,
@@ -308,7 +403,7 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
).run(input.reason, nowIso, nowIso, row.reservationId); ).run(input.reason, nowIso, nowIso, row.reservationId);
} }
ensureStateRow(row.prefix); ensureStateRow(db, row.prefix);
const state = db const state = db
.prepare( .prepare(
"SELECT committedClusterTaskCount FROM distributed_task_id_state WHERE prefix = ?", "SELECT committedClusterTaskCount FROM distributed_task_id_state WHERE prefix = ?",
@@ -334,7 +429,7 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
if (!prefix) { if (!prefix) {
throw new DistributedTaskIdError("prefix is required", "invalid_prefix"); throw new DistributedTaskIdError("prefix is required", "invalid_prefix");
} }
ensureStateRow(prefix); ensureStateRow(db, prefix);
const row = db const row = db
.prepare( .prepare(
`SELECT nextSequence, committedClusterTaskCount, lastCommittedTaskId `SELECT nextSequence, committedClusterTaskCount, lastCommittedTaskId

View File

@@ -28,7 +28,7 @@ import { validateNodeOverrideChange } from "./node-override-guard.js";
import { sanitizeTitle } from "./ai-summarize.js"; import { sanitizeTitle } from "./ai-summarize.js";
import { assertProjectRootDir } from "./project-root-guard.js"; import { assertProjectRootDir } from "./project-root-guard.js";
import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js"; import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js";
import { createDistributedTaskIdAllocator, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js"; import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
import { import {
buildBootstrapPrompt, buildBootstrapPrompt,
replicationCollisionError, replicationCollisionError,
@@ -585,6 +585,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private worktreeAllocationLock: Promise<void> = Promise.resolve(); private worktreeAllocationLock: Promise<void> = Promise.resolve();
/** Promise chain for serializing config.json read-modify-write cycles */ /** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve(); private configLock: Promise<void> = Promise.resolve();
/** Startup/open guard for distributed_task_id_state reconciliation. */
private taskIdStateReconciled = false;
/** Cached workflow steps — invalidated on create/update/delete */ /** Cached workflow steps — invalidated on create/update/delete */
private workflowStepsCache: import("./types.js").WorkflowStep[] | null = null; private workflowStepsCache: import("./types.js").WorkflowStep[] | null = null;
/** Plugin-contributed workflow step templates injected by engine runtime. */ /** Plugin-contributed workflow step templates injected by engine runtime. */
@@ -680,6 +682,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
throw error; throw error;
} }
this._db = db; this._db = db;
this.reconcileDistributedTaskIdStateOnOpen();
// Auto-migrate legacy data if needed // Auto-migrate legacy data if needed
if (detectLegacyData(this.fusionDir)) { if (detectLegacyData(this.fusionDir)) {
// Note: migrateFromLegacy is async but we need sync access. // Note: migrateFromLegacy is async but we need sync access.
@@ -705,6 +708,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this._archiveDb; return this._archiveDb;
} }
private reconcileDistributedTaskIdStateOnOpen(): void {
if (this.taskIdStateReconciled) {
return;
}
reconcileTaskIdState(this.db);
this.taskIdStateReconciled = true;
}
async init(): Promise<void> { async init(): Promise<void> {
await mkdir(this.tasksDir, { recursive: true }); await mkdir(this.tasksDir, { recursive: true });
@@ -720,18 +731,22 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this._db = db; this._db = db;
} }
this.reconcileDistributedTaskIdStateOnOpen();
// Auto-migrate from legacy file-based storage // Auto-migrate from legacy file-based storage
if (detectLegacyData(this.fusionDir)) { if (detectLegacyData(this.fusionDir)) {
await migrateFromLegacy(this.fusionDir, this._db); await migrateFromLegacy(this.fusionDir, this._db);
} }
await this.migrateActiveArchivedTasksToArchiveDb(); await this.migrateActiveArchivedTasksToArchiveDb();
await this.importLegacyAgentLogsOnce(); await this.importLegacyAgentLogsOnce();
this.taskIdStateReconciled = false;
this.reconcileDistributedTaskIdStateOnOpen();
// Write config.json for backward compatibility if it doesn't exist // Write config.json for backward compatibility if it doesn't exist
if (!existsSync(this.configPath)) { if (!existsSync(this.configPath)) {
const config = await this.readConfig(); const config = await this.readConfig();
try { try {
await writeFile(this.configPath, JSON.stringify(config, null, 2)); await writeFile(this.configPath, this.serializeConfigForDisk(config));
} catch (err) { } catch (err) {
storeLog.warn("Backward-compat config.json sync failed during init", { storeLog.warn("Backward-compat config.json sync failed during init", {
phase: "init:config-sync", phase: "init:config-sync",
@@ -1215,8 +1230,129 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return [...columns, limitedLog].join(", "); return [...columns, limitedLog].join(", ");
} }
private getTaskPersistValues(task: Task): unknown[] {
return [
task.id,
task.lineageId ?? generateTaskLineageId(),
task.title ?? null,
task.description,
normalizeTaskPriority(task.priority),
task.column,
task.status ?? null,
task.size ?? null,
task.reviewLevel ?? null,
task.currentStep || 0,
task.worktree ?? null,
task.blockedBy ?? null,
task.paused ? 1 : 0,
task.baseBranch ?? null,
task.branch ?? null,
task.executionStartBranch ?? null,
task.baseCommitSha ?? null,
task.modelPresetId ?? null,
task.modelProvider ?? null,
task.modelId ?? null,
task.validatorModelProvider ?? null,
task.validatorModelId ?? null,
task.planningModelProvider ?? null,
task.planningModelId ?? null,
task.mergeRetries ?? null,
task.workflowStepRetries ?? null,
task.stuckKillCount ?? 0,
task.postReviewFixCount ?? 0,
task.recoveryRetryCount ?? null,
task.taskDoneRetryCount ?? 0,
task.verificationFailureCount ?? 0,
task.mergeConflictBounceCount ?? 0,
task.nextRecoveryAt ?? null,
task.error ?? null,
task.summary ?? null,
task.thinkingLevel ?? null,
task.executionMode ?? null,
task.tokenUsage?.inputTokens ?? null,
task.tokenUsage?.outputTokens ?? null,
task.tokenUsage?.cachedTokens ?? null,
task.tokenUsage?.totalTokens ?? null,
task.tokenUsage?.firstUsedAt ?? null,
task.tokenUsage?.lastUsedAt ?? null,
task.createdAt,
task.updatedAt,
task.columnMovedAt ?? null,
task.executionStartedAt ?? null,
task.executionCompletedAt ?? null,
toJson(task.dependencies || []),
toJson(task.steps || []),
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJsonNullable(task.review),
toJsonNullable(task.reviewState),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
toJsonNullable(task.githubTracking),
task.sourceIssue?.provider ?? null,
task.sourceIssue?.repository ?? null,
task.sourceIssue?.externalIssueId ?? null,
task.sourceIssue?.issueNumber ?? null,
task.sourceIssue?.url ?? null,
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
toJson(task.modifiedFiles || []),
task.missionId ?? null,
task.sliceId ?? null,
task.assignedAgentId ?? null,
task.pausedByAgentId ?? null,
task.assigneeUserId ?? null,
task.nodeId ?? null,
task.effectiveNodeId ?? null,
task.effectiveNodeSource ?? null,
task.sourceType ?? null,
task.sourceAgentId ?? null,
task.sourceRunId ?? null,
task.sourceSessionId ?? null,
task.sourceMessageId ?? null,
task.sourceParentTaskId ?? null,
toJsonNullable(task.sourceMetadata),
task.checkedOutBy ?? null,
task.checkedOutAt ?? null,
task.checkoutNodeId ?? null,
task.checkoutRunId ?? null,
task.checkoutLeaseRenewedAt ?? null,
task.checkoutLeaseEpoch ?? 0,
];
}
/** /**
* Upsert a task to the database. Used by create and update operations. * Insert a brand-new task row. Create paths must use this so SQLite raises on
* duplicate IDs instead of silently rewriting the existing row.
*/
private insertTask(task: Task): void {
this.db.prepare(`
INSERT INTO tasks (
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`).run(...this.getTaskPersistValues(task));
this.db.bumpLastModified();
}
/**
* Upsert a task to the database. Update paths intentionally retain ON CONFLICT
* semantics; create paths must use insertTask() instead.
*/ */
private upsertTask(task: Task): void { private upsertTask(task: Task): void {
this.db.prepare(` this.db.prepare(`
@@ -1325,101 +1461,64 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
checkoutRunId = excluded.checkoutRunId, checkoutRunId = excluded.checkoutRunId,
checkoutLeaseRenewedAt = excluded.checkoutLeaseRenewedAt, checkoutLeaseRenewedAt = excluded.checkoutLeaseRenewedAt,
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch checkoutLeaseEpoch = excluded.checkoutLeaseEpoch
`).run( `).run(...this.getTaskPersistValues(task));
task.id,
task.lineageId ?? generateTaskLineageId(),
task.title ?? null,
task.description,
normalizeTaskPriority(task.priority),
task.column,
task.status ?? null,
task.size ?? null,
task.reviewLevel ?? null,
task.currentStep || 0,
task.worktree ?? null,
task.blockedBy ?? null,
task.paused ? 1 : 0,
task.baseBranch ?? null,
task.branch ?? null,
task.executionStartBranch ?? null,
task.baseCommitSha ?? null,
task.modelPresetId ?? null,
task.modelProvider ?? null,
task.modelId ?? null,
task.validatorModelProvider ?? null,
task.validatorModelId ?? null,
task.planningModelProvider ?? null,
task.planningModelId ?? null,
task.mergeRetries ?? null,
task.workflowStepRetries ?? null,
task.stuckKillCount ?? 0,
task.postReviewFixCount ?? 0,
task.recoveryRetryCount ?? null,
task.taskDoneRetryCount ?? 0,
task.verificationFailureCount ?? 0,
task.mergeConflictBounceCount ?? 0,
task.nextRecoveryAt ?? null,
task.error ?? null,
task.summary ?? null,
task.thinkingLevel ?? null,
task.executionMode ?? null,
task.tokenUsage?.inputTokens ?? null,
task.tokenUsage?.outputTokens ?? null,
task.tokenUsage?.cachedTokens ?? null,
task.tokenUsage?.totalTokens ?? null,
task.tokenUsage?.firstUsedAt ?? null,
task.tokenUsage?.lastUsedAt ?? null,
task.createdAt,
task.updatedAt,
task.columnMovedAt ?? null,
task.executionStartedAt ?? null,
task.executionCompletedAt ?? null,
toJson(task.dependencies || []),
toJson(task.steps || []),
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJsonNullable(task.review),
toJsonNullable(task.reviewState),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
toJsonNullable(task.githubTracking),
task.sourceIssue?.provider ?? null,
task.sourceIssue?.repository ?? null,
task.sourceIssue?.externalIssueId ?? null,
task.sourceIssue?.issueNumber ?? null,
task.sourceIssue?.url ?? null,
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
toJson(task.modifiedFiles || []),
task.missionId ?? null,
task.sliceId ?? null,
task.assignedAgentId ?? null,
task.pausedByAgentId ?? null,
task.assigneeUserId ?? null,
task.nodeId ?? null,
task.effectiveNodeId ?? null,
task.effectiveNodeSource ?? null,
task.sourceType ?? null,
task.sourceAgentId ?? null,
task.sourceRunId ?? null,
task.sourceSessionId ?? null,
task.sourceMessageId ?? null,
task.sourceParentTaskId ?? null,
toJsonNullable(task.sourceMetadata),
task.checkedOutBy ?? null,
task.checkedOutAt ?? null,
task.checkoutNodeId ?? null,
task.checkoutRunId ?? null,
task.checkoutLeaseRenewedAt ?? null,
task.checkoutLeaseEpoch ?? 0,
);
this.db.bumpLastModified(); this.db.bumpLastModified();
} }
private isTaskIdConflictError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /SQLITE_CONSTRAINT|UNIQUE constraint failed: tasks\.id|PRIMARY KEY constraint failed: tasks\.id/i.test(message);
}
private logTaskCreateConflict(task: Task, operation: string, error: unknown): void {
storeLog.error("Refused colliding task create", {
phase: "task-create:id-conflict",
operation,
taskId: task.id,
column: task.column,
sourceType: task.sourceType,
error: error instanceof Error ? error.message : String(error),
});
}
private insertTaskWithFtsRecovery(task: Task, operation: string): void {
const normalizeConflict = (error: unknown): never => {
this.logTaskCreateConflict(task, operation, error);
throw new Error(`Task ID already exists: ${task.id}`);
};
try {
this.insertTask(task);
return;
} catch (error) {
if (this.isTaskIdConflictError(error)) {
normalizeConflict(error);
}
if (!this.db.isFts5CorruptionError(error)) {
throw error;
}
console.warn(`[fusion:store] FTS5 corruption detected during insert for task ${task.id}; rebuilding index and retrying once`);
try {
this.db.rebuildFts5Index();
} catch (rebuildError) {
console.warn("[fusion:store] FTS5 rebuild failed; propagating original insert error", rebuildError);
throw error;
}
try {
this.insertTask(task);
} catch (retryError) {
if (this.isTaskIdConflictError(retryError)) {
normalizeConflict(retryError);
}
console.warn("[fusion:store] Insert retry after FTS5 rebuild failed; propagating original insert error", retryError);
throw error;
}
}
}
private upsertTaskWithFtsRecovery(task: Task): void { private upsertTaskWithFtsRecovery(task: Task): void {
try { try {
this.upsertTask(task); this.upsertTask(task);
@@ -1459,6 +1558,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this.rowToTask(row); return this.rowToTask(row);
} }
private isTaskIdPresentInArchivedTasksTable(id: string): boolean {
try {
const row = this.db.prepare("SELECT 1 as found FROM archivedTasks WHERE id = ? LIMIT 1").get(id) as { found?: number } | undefined;
return row?.found === 1;
} catch {
return false;
}
}
private taskIdExistsAnywhere(id: string): boolean {
if (this.readTaskFromDb(id)) {
return true;
}
if (this.isTaskIdPresentInArchivedTasksTable(id)) {
return true;
}
return this.archiveDb.get(id) !== undefined;
}
private assertTaskIdAvailable(id: string): void {
if (this.taskIdExistsAnywhere(id)) {
throw new Error(`Task ID already exists: ${id}`);
}
}
private isTaskArchived(id: string): boolean { private isTaskArchived(id: string): boolean {
const row = this.db.prepare('SELECT "column" FROM tasks WHERE id = ?').get(id) as { column: Column } | undefined; const row = this.db.prepare('SELECT "column" FROM tasks WHERE id = ?').get(id) as { column: Column } | undefined;
if (row) { if (row) {
@@ -1718,7 +1842,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} }
/** /**
* Write a task to SQLite (primary store) and also write task.json to disk * Write a brand-new task to SQLite (primary store) and also write task.json to disk
* for backward compatibility and debugging. Create paths must call this variant
* so duplicate IDs fail safely instead of overwriting existing rows.
*/
private async atomicCreateTaskJson(dir: string, task: Task, operation: string): Promise<void> {
this.insertTaskWithFtsRecovery(task, operation);
await this.writeTaskJsonFile(dir, task);
}
/**
* Write an existing task to SQLite (primary store) and also write task.json to disk
* for backward compatibility and debugging. * for backward compatibility and debugging.
*/ */
private async atomicWriteTaskJson(dir: string, task: Task): Promise<void> { private async atomicWriteTaskJson(dir: string, task: Task): Promise<void> {
@@ -2104,6 +2238,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}; };
} }
private serializeConfigForDisk(config: BoardConfig): string {
const { nextId: _deprecatedNextId, ...configForDisk } = config as BoardConfig & { nextId?: number };
return JSON.stringify(configForDisk, null, 2);
}
private async writeConfig( private async writeConfig(
config: BoardConfig, config: BoardConfig,
options?: { nextWorkflowStepId?: number }, options?: { nextWorkflowStepId?: number },
@@ -2119,12 +2258,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
? JSON.stringify(legacyWorkflowSteps) ? JSON.stringify(legacyWorkflowSteps)
: "[]"; : "[]";
// Use INSERT OR REPLACE to ensure the config row exists (handles edge case where row is missing) // `config.nextId` is deprecated legacy state. Preserve the existing column
// value for one release, but stop writing new values so distributed_task_id_state
// remains the sole active allocator counter.
this.db.prepare( this.db.prepare(
`INSERT OR REPLACE INTO config (id, nextId, nextWorkflowStepId, settings, workflowSteps, updatedAt) `INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt)
VALUES (1, ?, ?, ?, ?, ?)`, VALUES (1, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
nextWorkflowStepId = excluded.nextWorkflowStepId,
settings = excluded.settings,
workflowSteps = excluded.workflowSteps,
updatedAt = excluded.updatedAt`,
).run( ).run(
config.nextId || 1,
nextWorkflowStepId, nextWorkflowStepId,
JSON.stringify(config.settings || {}), JSON.stringify(config.settings || {}),
workflowStepsJson, workflowStepsJson,
@@ -2134,7 +2279,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Also write config.json to disk for backward compatibility // Also write config.json to disk for backward compatibility
try { try {
const tmpPath = this.configPath + ".tmp"; const tmpPath = this.configPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(config, null, 2)); await writeFile(tmpPath, this.serializeConfigForDisk(config));
await rename(tmpPath, this.configPath); await rename(tmpPath, this.configPath);
} catch (err) { } catch (err) {
// Best-effort: SQLite is the primary store // Best-effort: SQLite is the primary store
@@ -2469,9 +2614,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
throw new Error(`Task ${id} cannot depend on itself`); throw new Error(`Task ${id} cannot depend on itself`);
} }
if (this.readTaskFromDb(id)) { this.assertTaskIdAvailable(id);
throw new Error(`Task ID already exists: ${id}`);
}
const title = input.title?.trim() || undefined; const title = input.title?.trim() || undefined;
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
@@ -2589,9 +2732,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
updatedAt: options?.updatedAt ?? now, updatedAt: options?.updatedAt ?? now,
}; };
this.assertTaskIdAvailable(id);
const dir = this.taskDir(id); const dir = this.taskDir(id);
await mkdir(dir, { recursive: true }); await this.atomicCreateTaskJson(dir, task, "createTask");
await this.atomicWriteTaskJson(dir, task);
// Update cache if watcher is active // Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task }); if (this.isWatching) this.taskCache.set(id, { ...task });
@@ -2638,9 +2782,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
baseBranch: sourceTask.baseBranch, baseBranch: sourceTask.baseBranch,
}; };
this.assertTaskIdAvailable(newId);
const newDir = this.taskDir(newId); const newDir = this.taskDir(newId);
await mkdir(newDir, { recursive: true }); await this.atomicCreateTaskJson(newDir, newTask, "duplicateTask");
await this.atomicWriteTaskJson(newDir, newTask);
await mkdir(newDir, { recursive: true }); await mkdir(newDir, { recursive: true });
await writeFile(join(newDir, "PROMPT.md"), sourceTask.prompt); await writeFile(join(newDir, "PROMPT.md"), sourceTask.prompt);
@@ -2702,9 +2847,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined, attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
}; };
this.assertTaskIdAvailable(newId);
const newDir = this.taskDir(newId); const newDir = this.taskDir(newId);
await mkdir(newDir, { recursive: true }); await this.atomicCreateTaskJson(newDir, newTask, "refineTask");
await this.atomicWriteTaskJson(newDir, newTask);
const prompt = `# ${newTask.title}\n\n${newTask.description}\n`; const prompt = `# ${newTask.title}\n\n${newTask.description}\n`;
await mkdir(newDir, { recursive: true }); await mkdir(newDir, { recursive: true });
await writeFile(join(newDir, "PROMPT.md"), prompt); await writeFile(join(newDir, "PROMPT.md"), prompt);
@@ -6943,6 +7089,7 @@ ${stepsSection}`;
if (this._db) { if (this._db) {
this._db.close(); this._db.close();
this._db = null; this._db = null;
this.taskIdStateReconciled = false;
} }
if (this._archiveDb) { if (this._archiveDb) {
this._archiveDb.close(); this._archiveDb.close();