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:
5
.changeset/harden-task-id-overwrite-guards.md
Normal file
5
.changeset/harden-task-id-overwrite-guards.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
117
packages/core/src/__tests__/store-create-collision.test.ts
Normal file
117
packages/core/src/__tests__/store-create-collision.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user