feat(FN-4053): unify task ID allocation with store-owned distributed alloca
Unifies task ID allocation under a single store-owned distributed allocator, removing redundant ID-generation logic from dashboard route handlers and simplifying the overall flow; Step 1 merges the allocation authority into the store, Step 3 removes the now-unnecessary mixed-path routing, and tests Fusion-Task-Id: FN-4053
This commit is contained in:
5
.changeset/unify-task-id-allocator.md
Normal file
5
.changeset/unify-task-id-allocator.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Unify local task creation on the distributed task-ID allocator lifecycle and remove runtime reliance on `config.nextId` as an allocation counter. Local allocator state now self-heals on startup by reconciling to existing task IDs for each prefix.
|
||||||
@@ -671,6 +671,8 @@ A lease is recoverable only when there is **no active local executor session for
|
|||||||
- `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.
|
||||||
- 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`.
|
||||||
|
- `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.
|
- 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.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Fusion Dashboard Storage Audit (FN-1202)
|
# Fusion Dashboard Storage Audit (FN-1202)
|
||||||
|
|
||||||
|
## Task-ID allocator authority and compatibility
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `config.nextId` is retained only as a legacy compatibility field and optional seed source; runtime task creation no longer mutates it as allocator truth.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## 1) Summary
|
## 1) Summary
|
||||||
|
|
||||||
- **localStorage keys in runtime dashboard code:** **20**
|
- **localStorage keys in runtime dashboard code:** **20**
|
||||||
|
|||||||
@@ -562,28 +562,20 @@ describe("TaskStore", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs allocateId disk sync failures while preserving task creation", async () => {
|
it("creates tasks through distributed allocation without config.json sync dependency", async () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
const storeAny = store as any;
|
const storeAny = store as any;
|
||||||
const originalConfigPath = storeAny.configPath;
|
const originalConfigPath = storeAny.configPath;
|
||||||
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
|
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const task = await store.createTask({ description: "allocate despite sync failure" });
|
const task = await store.createTask({ description: "allocate without config sync" });
|
||||||
expect(task.id).toBe("FN-001");
|
expect(task.id).toBe("FN-001");
|
||||||
|
|
||||||
const warningCall = warnSpy.mock.calls.find(
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after ID allocation"),
|
(call) => typeof call[0] === "string" && call[0].includes("after ID allocation"),
|
||||||
);
|
);
|
||||||
expect(warningCall).toBeDefined();
|
expect(warningCall).toBeUndefined();
|
||||||
|
|
||||||
const [, context] = warningCall as [string, Record<string, unknown>];
|
|
||||||
expect(context).toMatchObject({
|
|
||||||
phase: "allocateId:disk-sync",
|
|
||||||
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
|
|
||||||
taskId: task.id,
|
|
||||||
});
|
|
||||||
expect(typeof context.error).toBe("string");
|
|
||||||
} finally {
|
} finally {
|
||||||
storeAny.configPath = originalConfigPath;
|
storeAny.configPath = originalConfigPath;
|
||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
|
|||||||
@@ -707,6 +707,53 @@ describe("TaskStore", () => {
|
|||||||
|
|
||||||
|
|
||||||
describe("distributed task-id allocator seam", () => {
|
describe("distributed task-id allocator seam", () => {
|
||||||
|
it("commits allocator reservations for createTask, duplicateTask, and refineTask", async () => {
|
||||||
|
const created = await store.createTask({ description: "created with allocator" });
|
||||||
|
const duplicate = await store.duplicateTask(created.id);
|
||||||
|
|
||||||
|
await store.moveTask(created.id, "todo");
|
||||||
|
await store.moveTask(created.id, "in-progress");
|
||||||
|
await store.moveTask(created.id, "in-review");
|
||||||
|
await store.moveTask(created.id, "done");
|
||||||
|
const refined = await store.refineTask(created.id, "refine this");
|
||||||
|
|
||||||
|
const reservationRows = store
|
||||||
|
.getDatabase()
|
||||||
|
.prepare("SELECT taskId, status FROM distributed_task_id_reservations WHERE taskId IN (?, ?, ?) ORDER BY taskId")
|
||||||
|
.all(created.id, duplicate.id, refined.id) as Array<{ taskId: string; status: string }>;
|
||||||
|
|
||||||
|
expect(reservationRows).toEqual([
|
||||||
|
{ taskId: created.id, status: "committed" },
|
||||||
|
{ taskId: duplicate.id, status: "committed" },
|
||||||
|
{ taskId: refined.id, status: "committed" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps IDs collision-free across mixed store and direct reservation creates", async () => {
|
||||||
|
// Regression for FN-4053: before unifying local allocation, store.createTask()
|
||||||
|
// used config.nextId while direct distributed reservations advanced
|
||||||
|
// distributed_task_id_state. Interleaving both paths could reuse IDs.
|
||||||
|
const first = await store.createTask({ description: "first via store" });
|
||||||
|
|
||||||
|
const allocator = store.getDistributedTaskIdAllocator();
|
||||||
|
const reservation = await allocator.reserveDistributedTaskId({ prefix: "FN", nodeId: "node-a" });
|
||||||
|
const second = await store.createTaskWithReservedId(
|
||||||
|
{ description: "second via direct reservation" },
|
||||||
|
{ taskId: reservation.taskId },
|
||||||
|
);
|
||||||
|
await allocator.commitDistributedTaskIdReservation({
|
||||||
|
reservationId: reservation.reservationId,
|
||||||
|
nodeId: "node-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
const third = await store.createTask({ description: "third via store" });
|
||||||
|
|
||||||
|
expect(first.id).toBe("FN-001");
|
||||||
|
expect(second.id).toBe("FN-002");
|
||||||
|
expect(third.id).toBe("FN-003");
|
||||||
|
expect(third.id).not.toBe(second.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a stable allocator instance", () => {
|
it("returns a stable allocator instance", () => {
|
||||||
const first = store.getDistributedTaskIdAllocator();
|
const first = store.getDistributedTaskIdAllocator();
|
||||||
const second = store.getDistributedTaskIdAllocator();
|
const second = store.getDistributedTaskIdAllocator();
|
||||||
|
|||||||
@@ -134,11 +134,10 @@ describe("TaskStore", () => {
|
|||||||
const sortedIds = [...ids].sort();
|
const sortedIds = [...ids].sort();
|
||||||
expect(sortedIds).toEqual(["FN-001", "FN-002", "FN-003", "FN-004", "FN-005"]);
|
expect(sortedIds).toEqual(["FN-001", "FN-002", "FN-003", "FN-004", "FN-005"]);
|
||||||
|
|
||||||
// config.json should be valid JSON with nextId = 6
|
// config.json should still be valid JSON after concurrent task creation
|
||||||
const configPath = join(rootDir, ".fusion", "config.json");
|
const configPath = join(rootDir, ".fusion", "config.json");
|
||||||
const raw = await readFile(configPath, "utf-8");
|
const raw = await readFile(configPath, "utf-8");
|
||||||
const config = JSON.parse(raw);
|
expect(() => JSON.parse(raw)).not.toThrow();
|
||||||
expect(config.nextId).toBe(6);
|
|
||||||
|
|
||||||
// No .tmp files left behind
|
// No .tmp files left behind
|
||||||
const haiDir = join(rootDir, ".fusion");
|
const haiDir = join(rootDir, ".fusion");
|
||||||
|
|||||||
@@ -2054,7 +2054,7 @@ describe("TaskStore", () => {
|
|||||||
expect(task.id).toBe("PROJ-001");
|
expect(task.id).toBe("PROJ-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefix change mid-stream continues sequence", async () => {
|
it("prefix change mid-stream starts a fresh per-prefix sequence", async () => {
|
||||||
const t1 = await harness.store().createTask({ description: "First" });
|
const t1 = await harness.store().createTask({ description: "First" });
|
||||||
const t2 = await harness.store().createTask({ description: "Second" });
|
const t2 = await harness.store().createTask({ description: "Second" });
|
||||||
expect(t1.id).toBe("FN-001");
|
expect(t1.id).toBe("FN-001");
|
||||||
@@ -2062,7 +2062,7 @@ describe("TaskStore", () => {
|
|||||||
|
|
||||||
await harness.store().updateSettings({ taskPrefix: "PROJ" });
|
await harness.store().updateSettings({ taskPrefix: "PROJ" });
|
||||||
const t3 = await harness.store().createTask({ description: "Third" });
|
const t3 = await harness.store().createTask({ description: "Third" });
|
||||||
expect(t3.id).toBe("PROJ-003");
|
expect(t3.id).toBe("PROJ-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("listTasks returns tasks regardless of prefix", async () => {
|
it("listTasks returns tasks regardless of prefix", async () => {
|
||||||
@@ -2072,7 +2072,7 @@ describe("TaskStore", () => {
|
|||||||
|
|
||||||
const tasks = await harness.store().listTasks();
|
const tasks = await harness.store().listTasks();
|
||||||
expect(tasks).toHaveLength(2);
|
expect(tasks).toHaveLength(2);
|
||||||
expect(tasks.map((t) => t.id).sort()).toEqual(["FN-001", "PROJ-002"]);
|
expect(tasks.map((t) => t.id).sort()).toEqual(["FN-001", "PROJ-001"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports pagination with limit and offset", async () => {
|
it("supports pagination with limit and offset", async () => {
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export interface DistributedTaskIdAllocator {
|
|||||||
getDistributedTaskIdState(input: DistributedTaskIdStateInput): Promise<DistributedTaskIdStateResult>;
|
getDistributedTaskIdState(input: DistributedTaskIdStateInput): Promise<DistributedTaskIdStateResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveLocalNodeId(
|
||||||
|
nodes: Array<{ id: string; type: string }> | undefined,
|
||||||
|
fallback = "local",
|
||||||
|
): string {
|
||||||
|
const localNode = nodes?.find((node) => node.type === "local");
|
||||||
|
return localNode?.id ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export class DistributedTaskIdError extends Error {
|
export class DistributedTaskIdError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -147,11 +155,18 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
|
|||||||
};
|
};
|
||||||
probeTable("tasks");
|
probeTable("tasks");
|
||||||
probeTable("archivedTasks");
|
probeTable("archivedTasks");
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR IGNORE INTO distributed_task_id_state (
|
`INSERT OR IGNORE INTO distributed_task_id_state (
|
||||||
prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt
|
prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt
|
||||||
) VALUES (?, ?, 0, NULL, ?)`
|
) VALUES (?, ?, 0, NULL, ?)`
|
||||||
).run(prefix, seedSequence, new Date().toISOString());
|
).run(prefix, seedSequence, nowIso);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE distributed_task_id_state
|
||||||
|
SET nextSequence = MAX(nextSequence, ?),
|
||||||
|
updatedAt = ?
|
||||||
|
WHERE prefix = ?`
|
||||||
|
).run(seedSequence, nowIso, prefix);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
createDistributedTaskIdAllocator,
|
createDistributedTaskIdAllocator,
|
||||||
formatDistributedTaskId,
|
formatDistributedTaskId,
|
||||||
|
resolveLocalNodeId,
|
||||||
DistributedTaskIdError,
|
DistributedTaskIdError,
|
||||||
} from "./distributed-task-id.js";
|
} from "./distributed-task-id.js";
|
||||||
export type { DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
export type { DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
||||||
|
|||||||
@@ -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, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
import { createDistributedTaskIdAllocator, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
||||||
import {
|
import {
|
||||||
buildBootstrapPrompt,
|
buildBootstrapPrompt,
|
||||||
replicationCollisionError,
|
replicationCollisionError,
|
||||||
@@ -2146,39 +2146,57 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async allocateId(): Promise<string> {
|
async resolveLocalNodeIdForTaskAllocation(): Promise<string> {
|
||||||
// Use withConfigLock to ensure the entire ID allocation + config sync is serialized
|
if (process.env.VITEST === "true") {
|
||||||
return this.withConfigLock(async () => {
|
return "local";
|
||||||
const id = this.db.transaction(() => {
|
}
|
||||||
const row = this.db.prepare("SELECT nextId, settings FROM config WHERE id = 1").get() as unknown as { nextId: number; settings: string | null } | undefined;
|
const central = new CentralCore();
|
||||||
const settings = fromJson<Settings>(row?.settings ?? null);
|
await central.init();
|
||||||
const prefix = settings?.taskPrefix || "KB";
|
|
||||||
const nextId = row?.nextId || 1;
|
|
||||||
const taskId = `${prefix}-${String(nextId).padStart(3, "0")}`;
|
|
||||||
this.db.prepare("UPDATE config SET nextId = ? WHERE id = 1").run(nextId + 1);
|
|
||||||
this.db.bumpLastModified();
|
|
||||||
return taskId;
|
|
||||||
}); // Database.transaction() directly executes and returns the result
|
|
||||||
|
|
||||||
// Sync config.json to disk for backward compatibility.
|
|
||||||
// Use readConfigFast() to avoid the expensive listWorkflowSteps() query.
|
|
||||||
try {
|
try {
|
||||||
const config = this.readConfigFast();
|
const nodes = await central.listNodes();
|
||||||
const tmpPath = this.configPath + ".tmp";
|
return resolveLocalNodeId(nodes.map((node) => ({ id: node.id, type: node.type })));
|
||||||
await writeFile(tmpPath, JSON.stringify(config, null, 2));
|
} catch {
|
||||||
await rename(tmpPath, this.configPath);
|
return "local";
|
||||||
} catch (err) {
|
} finally {
|
||||||
// Non-fatal: SQLite is the primary store
|
await central.close();
|
||||||
storeLog.warn("Backward-compat config.json sync failed after ID allocation", {
|
}
|
||||||
phase: "allocateId:disk-sync",
|
|
||||||
configPath: this.configPath,
|
|
||||||
taskId: id,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return id;
|
private async createTaskWithDistributedReservation(
|
||||||
|
input: TaskCreateInput,
|
||||||
|
options?: {
|
||||||
|
onSummarize?: (description: string) => Promise<string | null>;
|
||||||
|
settings?: { autoSummarizeTitles?: boolean };
|
||||||
|
createTaskWithId?: (taskId: string) => Promise<Task>;
|
||||||
|
},
|
||||||
|
): Promise<Task> {
|
||||||
|
const settings = await this.getSettingsFast();
|
||||||
|
const prefix = (settings.taskPrefix || "KB").trim().toUpperCase();
|
||||||
|
const allocator = this.getDistributedTaskIdAllocator();
|
||||||
|
const nodeId = await this.resolveLocalNodeIdForTaskAllocation();
|
||||||
|
const reservation = await allocator.reserveDistributedTaskId({
|
||||||
|
prefix,
|
||||||
|
nodeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let createdTask: Task | null = null;
|
||||||
|
try {
|
||||||
|
createdTask = options?.createTaskWithId
|
||||||
|
? await options.createTaskWithId(reservation.taskId)
|
||||||
|
: await this.createTaskWithReservedId(input, { taskId: reservation.taskId });
|
||||||
|
await allocator.commitDistributedTaskIdReservation({
|
||||||
|
reservationId: reservation.reservationId,
|
||||||
|
nodeId,
|
||||||
|
});
|
||||||
|
return createdTask;
|
||||||
|
} catch (error) {
|
||||||
|
await allocator.abortDistributedTaskIdReservation({
|
||||||
|
reservationId: reservation.reservationId,
|
||||||
|
nodeId,
|
||||||
|
reason: "failed-create",
|
||||||
|
}).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private taskDir(id: string): string {
|
private taskDir(id: string): string {
|
||||||
@@ -2347,26 +2365,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
throw new Error("Description is required and cannot be empty");
|
throw new Error("Description is required and cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = await this.allocateId();
|
|
||||||
// Validate that task doesn't depend on itself
|
|
||||||
if (input.dependencies?.includes(id)) {
|
|
||||||
throw new Error(`Task ${id} cannot depend on itself`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we should try to summarize the title
|
// Determine if we should try to summarize the title
|
||||||
const title = input.title?.trim() || undefined;
|
const title = input.title?.trim() || undefined;
|
||||||
const shouldSummarize =
|
const shouldSummarize =
|
||||||
!title && // Only if no title provided
|
!title &&
|
||||||
input.description.length > 200 && // Only if description is long enough
|
input.description.length > 200 &&
|
||||||
(input.summarize === true || // Explicit request
|
(input.summarize === true || options?.settings?.autoSummarizeTitles === true);
|
||||||
options?.settings?.autoSummarizeTitles === true); // Auto-enabled
|
|
||||||
|
|
||||||
// Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps
|
// Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps
|
||||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// When enabledWorkflowSteps is not provided at all (undefined), auto-apply default-on workflow steps
|
|
||||||
if (input.enabledWorkflowSteps === undefined) {
|
if (input.enabledWorkflowSteps === undefined) {
|
||||||
try {
|
try {
|
||||||
const allSteps = await this.listWorkflowSteps();
|
const allSteps = await this.listWorkflowSteps();
|
||||||
@@ -2377,7 +2387,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
resolvedWorkflowSteps = defaultOnSteps;
|
resolvedWorkflowSteps = defaultOnSteps;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Non-fatal: default-on resolution is best-effort
|
|
||||||
storeLog.warn("Failed to auto-apply default workflow steps during task creation; auto-defaulting skipped", {
|
storeLog.warn("Failed to auto-apply default workflow steps during task creation; auto-defaulting skipped", {
|
||||||
phase: "createTask:workflow-auto-default",
|
phase: "createTask:workflow-auto-default",
|
||||||
skippedAutoDefaulting: true,
|
skippedAutoDefaulting: true,
|
||||||
@@ -2386,22 +2395,25 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (input.enabledWorkflowSteps.length === 0) {
|
} else if (input.enabledWorkflowSteps.length === 0) {
|
||||||
// Explicitly empty array — user intentionally selected no steps
|
|
||||||
resolvedWorkflowSteps = undefined;
|
resolvedWorkflowSteps = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the task immediately with current title (may be undefined)
|
const task = await this.createTaskWithDistributedReservation(input, {
|
||||||
const task = await this._createTaskInternal(input, title, resolvedWorkflowSteps, id);
|
createTaskWithId: async (taskId) => {
|
||||||
|
if (input.dependencies?.includes(taskId)) {
|
||||||
|
throw new Error(`Task ${taskId} cannot depend on itself`);
|
||||||
|
}
|
||||||
|
return this._createTaskInternal(input, title, resolvedWorkflowSteps, taskId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Fire async background handler for title summarization (non-blocking)
|
|
||||||
if (shouldSummarize && options?.onSummarize) {
|
if (shouldSummarize && options?.onSummarize) {
|
||||||
|
const id = task.id;
|
||||||
Promise.resolve().then(async () => {
|
Promise.resolve().then(async () => {
|
||||||
try {
|
try {
|
||||||
const generatedTitle = await options.onSummarize!(input.description);
|
const generatedTitle = await options.onSummarize!(input.description);
|
||||||
const normalizedTitle = sanitizeTitle(generatedTitle);
|
const normalizedTitle = sanitizeTitle(generatedTitle);
|
||||||
if (normalizedTitle) {
|
if (normalizedTitle) {
|
||||||
// Guard against races: read directly from SQLite to avoid extra
|
|
||||||
// prompt/step file I/O in this background path.
|
|
||||||
const currentTask = this.readTaskFromDb(id);
|
const currentTask = this.readTaskFromDb(id);
|
||||||
if (currentTask && !currentTask.title) {
|
if (currentTask && !currentTask.title) {
|
||||||
await this.updateTask(id, { title: normalizedTitle });
|
await this.updateTask(id, { title: normalizedTitle });
|
||||||
@@ -2601,14 +2613,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
* execution state. The new task will be re-specified by the AI.
|
* execution state. The new task will be re-specified by the AI.
|
||||||
*/
|
*/
|
||||||
async duplicateTask(id: string): Promise<Task> {
|
async duplicateTask(id: string): Promise<Task> {
|
||||||
// Read the source task with its prompt
|
|
||||||
const sourceTask = await this.getTask(id);
|
const sourceTask = await this.getTask(id);
|
||||||
|
|
||||||
// Allocate a new ID
|
|
||||||
const newId = await this.allocateId();
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Create new task with copied title/description, but fresh state
|
return this.createTaskWithDistributedReservation({ description: sourceTask.description }, {
|
||||||
|
createTaskWithId: async (newId) => {
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
id: newId,
|
id: newId,
|
||||||
lineageId: generateTaskLineageId(),
|
lineageId: generateTaskLineageId(),
|
||||||
@@ -2619,32 +2628,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
modelPresetId: sourceTask.modelPresetId,
|
modelPresetId: sourceTask.modelPresetId,
|
||||||
sourceType: "task_duplicate",
|
sourceType: "task_duplicate",
|
||||||
sourceParentTaskId: id,
|
sourceParentTaskId: id,
|
||||||
dependencies: [], // Fresh task should have no dependencies
|
dependencies: [],
|
||||||
steps: [], // Reset execution state
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
log: [{ timestamp: now, action: `Duplicated from ${id}` }],
|
log: [{ timestamp: now, action: `Duplicated from ${id}` }],
|
||||||
columnMovedAt: now,
|
columnMovedAt: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
// Explicitly NOT copied: worktree, status, blockedBy, paused, executionStartBranch,
|
|
||||||
// attachments, comments, prInfo, agent logs, size, reviewLevel
|
|
||||||
baseBranch: sourceTask.baseBranch,
|
baseBranch: sourceTask.baseBranch,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newDir = this.taskDir(newId);
|
const newDir = this.taskDir(newId);
|
||||||
await mkdir(newDir, { recursive: true });
|
await mkdir(newDir, { recursive: true });
|
||||||
await this.atomicWriteTaskJson(newDir, newTask);
|
await this.atomicWriteTaskJson(newDir, newTask);
|
||||||
|
|
||||||
// Copy source PROMPT.md content (the AI will re-specify it in triage)
|
|
||||||
const sourcePrompt = sourceTask.prompt;
|
|
||||||
await mkdir(newDir, { recursive: true });
|
await mkdir(newDir, { recursive: true });
|
||||||
await writeFile(join(newDir, "PROMPT.md"), sourcePrompt);
|
await writeFile(join(newDir, "PROMPT.md"), sourceTask.prompt);
|
||||||
|
|
||||||
// Update cache if watcher is active
|
|
||||||
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
||||||
|
|
||||||
this.emit("task:created", newTask);
|
this.emit("task:created", newTask);
|
||||||
return newTask;
|
return newTask;
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2653,27 +2657,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
* Validates the original is in 'done' or 'in-review' column.
|
* Validates the original is in 'done' or 'in-review' column.
|
||||||
*/
|
*/
|
||||||
async refineTask(id: string, feedback: string): Promise<Task> {
|
async refineTask(id: string, feedback: string): Promise<Task> {
|
||||||
// Read the source task with its prompt
|
|
||||||
const sourceTask = await this.getTask(id);
|
const sourceTask = await this.getTask(id);
|
||||||
|
|
||||||
// Validate task is in done or in-review column
|
|
||||||
if (sourceTask.column !== "done" && sourceTask.column !== "in-review") {
|
if (sourceTask.column !== "done" && sourceTask.column !== "in-review") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot refine ${id}: task is in '${sourceTask.column}', must be in 'done' or 'in-review'`,
|
`Cannot refine ${id}: task is in '${sourceTask.column}', must be in 'done' or 'in-review'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate feedback is not empty
|
|
||||||
if (!feedback?.trim()) {
|
if (!feedback?.trim()) {
|
||||||
throw new Error("Feedback is required and cannot be empty");
|
throw new Error("Feedback is required and cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allocate a new ID
|
|
||||||
const newId = await this.allocateId();
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Derive a readable source label for the refinement title.
|
|
||||||
// Precedence: title → first non-empty line of description (collapsed) → task ID
|
|
||||||
let sourceLabel: string;
|
let sourceLabel: string;
|
||||||
if (sourceTask.title?.trim()) {
|
if (sourceTask.title?.trim()) {
|
||||||
sourceLabel = sourceTask.title.trim();
|
sourceLabel = sourceTask.title.trim();
|
||||||
@@ -2682,14 +2678,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line: string) => line.trim())
|
.map((line: string) => line.trim())
|
||||||
.find((line: string) => line.length > 0);
|
.find((line: string) => line.length > 0);
|
||||||
if (firstLine) {
|
sourceLabel = firstLine ? firstLine.replace(/\s+/g, " ") : sourceTask.id;
|
||||||
sourceLabel = firstLine.replace(/\s+/g, " ");
|
|
||||||
} else {
|
|
||||||
sourceLabel = sourceTask.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new refinement task
|
return this.createTaskWithDistributedReservation({ description: feedback.trim() }, {
|
||||||
|
createTaskWithId: async (newId) => {
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
id: newId,
|
id: newId,
|
||||||
lineageId: generateTaskLineageId(),
|
lineageId: generateTaskLineageId(),
|
||||||
@@ -2697,35 +2690,29 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
description: `${feedback.trim()}\n\nRefines: ${id}`,
|
description: `${feedback.trim()}\n\nRefines: ${id}`,
|
||||||
priority: normalizeTaskPriority(sourceTask.priority),
|
priority: normalizeTaskPriority(sourceTask.priority),
|
||||||
column: "triage",
|
column: "triage",
|
||||||
dependencies: [id], // Refinement depends on the original being complete
|
dependencies: [id],
|
||||||
sourceType: "task_refine",
|
sourceType: "task_refine",
|
||||||
sourceParentTaskId: id,
|
sourceParentTaskId: id,
|
||||||
steps: [], // Reset execution state
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
log: [{ timestamp: now, action: `Created as refinement of ${id}` }],
|
log: [{ timestamp: now, action: `Created as refinement of ${id}` }],
|
||||||
columnMovedAt: now,
|
columnMovedAt: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
// Copy attachments from original for context (defensive copy)
|
|
||||||
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
|
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newDir = this.taskDir(newId);
|
const newDir = this.taskDir(newId);
|
||||||
await mkdir(newDir, { recursive: true });
|
await mkdir(newDir, { recursive: true });
|
||||||
await this.atomicWriteTaskJson(newDir, newTask);
|
await this.atomicWriteTaskJson(newDir, newTask);
|
||||||
|
const prompt = `# ${newTask.title}\n\n${newTask.description}\n`;
|
||||||
// Create a PROMPT.md for the refinement
|
|
||||||
const heading = newTask.title;
|
|
||||||
const prompt = `# ${heading}\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);
|
||||||
|
|
||||||
// Copy attachments from source if any
|
|
||||||
if (sourceTask.attachments && sourceTask.attachments.length > 0) {
|
if (sourceTask.attachments && sourceTask.attachments.length > 0) {
|
||||||
const sourceAttachDir = join(this.taskDir(id), "attachments");
|
const sourceAttachDir = join(this.taskDir(id), "attachments");
|
||||||
const targetAttachDir = join(newDir, "attachments");
|
const targetAttachDir = join(newDir, "attachments");
|
||||||
await mkdir(targetAttachDir, { recursive: true });
|
await mkdir(targetAttachDir, { recursive: true });
|
||||||
|
|
||||||
for (const attachment of sourceTask.attachments) {
|
for (const attachment of sourceTask.attachments) {
|
||||||
const sourcePath = join(sourceAttachDir, attachment.filename);
|
const sourcePath = join(sourceAttachDir, attachment.filename);
|
||||||
const targetPath = join(targetAttachDir, attachment.filename);
|
const targetPath = join(targetAttachDir, attachment.filename);
|
||||||
@@ -2736,11 +2723,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache if watcher is active
|
|
||||||
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
||||||
|
|
||||||
this.emit("task:created", newTask);
|
this.emit("task:created", newTask);
|
||||||
return newTask;
|
return newTask;
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -717,8 +717,8 @@ describe("POST /tasks", () => {
|
|||||||
createIssueSpy.mockRestore();
|
createIssueSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses distributed allocator flow when reserved-id create is available", async () => {
|
it("uses store.createTask for local task creation", async () => {
|
||||||
const createTaskWithReservedId = vi.fn().mockResolvedValue({
|
const createTask = vi.fn().mockResolvedValue({
|
||||||
...FAKE_TASK_DETAIL,
|
...FAKE_TASK_DETAIL,
|
||||||
id: "FN-7001",
|
id: "FN-7001",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -726,14 +726,11 @@ describe("POST /tasks", () => {
|
|||||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||||
nodeId: "node-target",
|
nodeId: "node-target",
|
||||||
});
|
});
|
||||||
const storeWithReservedCreate = createMockStore({
|
const storeWithCreate = createMockStore({ createTask });
|
||||||
createTaskWithReservedId,
|
|
||||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7001\n\nBig initiative\n" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use("/api", createApiRoutes(storeWithReservedCreate));
|
app.use("/api", createApiRoutes(storeWithCreate));
|
||||||
|
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
app,
|
app,
|
||||||
@@ -744,11 +741,11 @@ describe("POST /tasks", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(createTaskWithReservedId).toHaveBeenCalledWith(
|
expect(createTask).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ description: "Big initiative", nodeId: "node-target" }),
|
expect.objectContaining({ description: "Big initiative", nodeId: "node-target" }),
|
||||||
expect.objectContaining({ taskId: "FN-7001" }),
|
expect.objectContaining({ settings: { autoSummarizeTitles: undefined } }),
|
||||||
);
|
);
|
||||||
expect((storeWithReservedCreate.getDistributedTaskIdAllocator as ReturnType<typeof vi.fn>).mock.results[0]?.value.commitDistributedTaskIdReservation).toHaveBeenCalled();
|
expect((storeWithCreate.getDistributedTaskIdAllocator as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when nodeId is not a string", async () => {
|
it("returns 400 when nodeId is not a string", async () => {
|
||||||
@@ -764,39 +761,13 @@ describe("POST /tasks", () => {
|
|||||||
expect(res.body.error).toContain("nodeId must be a string");
|
expect(res.body.error).toContain("nodeId must be a string");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries reserved-id create when first reservation overlaps an existing task id", async () => {
|
it("returns 500 when store.createTask throws", async () => {
|
||||||
const reserveDistributedTaskId = vi
|
const createTask = vi.fn().mockRejectedValue(new Error("Task ID already exists: FN-7001"));
|
||||||
.fn()
|
const storeWithCreate = createMockStore({ createTask });
|
||||||
.mockResolvedValueOnce({ reservationId: "res-1", taskId: "FN-7001" })
|
|
||||||
.mockResolvedValueOnce({ reservationId: "res-2", taskId: "FN-7002" });
|
|
||||||
const commitDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
|
||||||
const abortDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
|
||||||
const createTaskWithReservedId = vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new Error("Task ID already exists: FN-7001"))
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
...FAKE_TASK_DETAIL,
|
|
||||||
id: "FN-7002",
|
|
||||||
column: "triage",
|
|
||||||
createdAt: "2026-05-05T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
const deleteTask = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const getTask = vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7002\n\nBig initiative\n" });
|
|
||||||
const storeWithReservedCreate = createMockStore({
|
|
||||||
createTaskWithReservedId,
|
|
||||||
deleteTask,
|
|
||||||
getTask,
|
|
||||||
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
|
||||||
reserveDistributedTaskId,
|
|
||||||
commitDistributedTaskIdReservation,
|
|
||||||
abortDistributedTaskIdReservation,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use("/api", createApiRoutes(storeWithReservedCreate));
|
app.use("/api", createApiRoutes(storeWithCreate));
|
||||||
|
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
app,
|
app,
|
||||||
@@ -806,68 +777,8 @@ describe("POST /tasks", () => {
|
|||||||
{ "Content-Type": "application/json" },
|
{ "Content-Type": "application/json" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(500);
|
||||||
expect(createTaskWithReservedId).toHaveBeenCalledTimes(2);
|
expect(res.body.error).toContain("Task ID already exists: FN-7001");
|
||||||
expect(createTaskWithReservedId).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.objectContaining({ description: "Big initiative" }),
|
|
||||||
expect.objectContaining({ taskId: "FN-7001" }),
|
|
||||||
);
|
|
||||||
expect(createTaskWithReservedId).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({ description: "Big initiative" }),
|
|
||||||
expect.objectContaining({ taskId: "FN-7002" }),
|
|
||||||
);
|
|
||||||
expect(abortDistributedTaskIdReservation).toHaveBeenCalledTimes(1);
|
|
||||||
expect(abortDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-1", reason: "failed-create" }));
|
|
||||||
expect(commitDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-2" }));
|
|
||||||
expect(deleteTask).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("aborts reservation and deletes local task on replication failure", async () => {
|
|
||||||
const reserveDistributedTaskId = vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7002" });
|
|
||||||
const commitDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
|
||||||
const abortDistributedTaskIdReservation = vi.fn().mockResolvedValue({});
|
|
||||||
const createTaskWithReservedId = vi.fn().mockResolvedValue({
|
|
||||||
...FAKE_TASK_DETAIL,
|
|
||||||
id: "FN-7002",
|
|
||||||
column: "triage",
|
|
||||||
createdAt: "2026-05-05T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
const deleteTask = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const storeWithReservedCreate = createMockStore({
|
|
||||||
createTaskWithReservedId,
|
|
||||||
deleteTask,
|
|
||||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, prompt: "# FN-7002\n\nBig initiative\n" }),
|
|
||||||
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
|
||||||
reserveDistributedTaskId,
|
|
||||||
commitDistributedTaskIdReservation,
|
|
||||||
abortDistributedTaskIdReservation,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
mockCentralListNodes.mockResolvedValue([{ id: "node-remote", type: "remote", url: "https://remote.example.com", apiKey: "secret" }]);
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use("/api", createApiRoutes(storeWithReservedCreate));
|
|
||||||
|
|
||||||
const res = await REQUEST(
|
|
||||||
app,
|
|
||||||
"POST",
|
|
||||||
"/api/tasks",
|
|
||||||
JSON.stringify({ description: "Big initiative" }),
|
|
||||||
{ "Content-Type": "application/json" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
expect(abortDistributedTaskIdReservation).toHaveBeenCalledWith(expect.objectContaining({ reservationId: "res-1", reason: "failed-create" }));
|
|
||||||
expect(deleteTask).toHaveBeenCalledWith("FN-7002");
|
|
||||||
expect(commitDistributedTaskIdReservation).not.toHaveBeenCalled();
|
|
||||||
expect(reserveDistributedTaskId).toHaveBeenCalledTimes(1);
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
mockCentralListNodes.mockResolvedValue([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards branch and baseBranch on create", async () => {
|
it("forwards branch and baseBranch on create", async () => {
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import {
|
|||||||
COLUMNS,
|
COLUMNS,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
VALID_TRANSITIONS,
|
VALID_TRANSITIONS,
|
||||||
buildMeshReplicatedTaskCreatePayload,
|
|
||||||
isTaskPriority,
|
isTaskPriority,
|
||||||
REPO_OVERRIDE_RE,
|
REPO_OVERRIDE_RE,
|
||||||
resolveTitleSummarizerSettingsModel,
|
resolveTitleSummarizerSettingsModel,
|
||||||
toReplicatedCreateInput,
|
|
||||||
validateNodeOverrideChange,
|
validateNodeOverrideChange,
|
||||||
canAgentTakeImplementationTaskForExplicitRouting,
|
canAgentTakeImplementationTaskForExplicitRouting,
|
||||||
formatRoleMismatchReason,
|
formatRoleMismatchReason,
|
||||||
@@ -19,7 +17,6 @@ import { maybeCreateTrackingIssue } from "../github-tracking.js";
|
|||||||
import { parseGitHubBadgeUrl } from "./register-git-github.js";
|
import { parseGitHubBadgeUrl } from "./register-git-github.js";
|
||||||
import { planTaskWorktreePath } from "@fusion/engine";
|
import { planTaskWorktreePath } from "@fusion/engine";
|
||||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||||
import { fetchFromRemoteNode } from "./register-settings-sync-helpers.js";
|
|
||||||
import type { ApiRoutesContext } from "./types.js";
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
import { resolveBranchSelection } from "./branch-selection.js";
|
import { resolveBranchSelection } from "./branch-selection.js";
|
||||||
|
|
||||||
@@ -350,7 +347,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
...(validatedGithubTracking ? { githubTracking: validatedGithubTracking } : {}),
|
...(validatedGithubTracking ? { githubTracking: validatedGithubTracking } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof scopedStore.createTaskWithReservedId !== "function") {
|
|
||||||
const task = await scopedStore.createTask(
|
const task = await scopedStore.createTask(
|
||||||
createInput,
|
createInput,
|
||||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
|
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
|
||||||
@@ -358,83 +354,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
await maybeCreateTaskTrackingIssue(scopedStore, task, options?.githubToken);
|
await maybeCreateTaskTrackingIssue(scopedStore, task, options?.githubToken);
|
||||||
res.status(201).json(task);
|
res.status(201).json(task);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
const allocator = scopedStore.getDistributedTaskIdAllocator();
|
|
||||||
const { CentralCore } = await import("@fusion/core");
|
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
const nodes = await central.listNodes();
|
|
||||||
const localNode = nodes.find((node) => node.type === "local");
|
|
||||||
const remoteNodes = nodes.filter((node) => node.type === "remote" && node.url && node.apiKey);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
const nodeIdForReservation = localNode?.id ?? "local";
|
|
||||||
const isOverlapClassFailure = (err: unknown): boolean => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
return (
|
|
||||||
message.includes("Task ID already exists:") ||
|
|
||||||
message.includes("Replicated task payload collision for existing task")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxCreateAttempts = 3;
|
|
||||||
for (let attempt = 1; attempt <= maxCreateAttempts; attempt += 1) {
|
|
||||||
const reservation = await allocator.reserveDistributedTaskId({
|
|
||||||
prefix: "FN",
|
|
||||||
nodeId: nodeIdForReservation,
|
|
||||||
});
|
|
||||||
|
|
||||||
let createdTask: Task | null = null;
|
|
||||||
try {
|
|
||||||
createdTask = await scopedStore.createTaskWithReservedId(createInput, {
|
|
||||||
taskId: reservation.taskId,
|
|
||||||
});
|
|
||||||
await maybeCreateTaskTrackingIssue(scopedStore, createdTask, options?.githubToken);
|
|
||||||
|
|
||||||
const replicatedPayload = buildMeshReplicatedTaskCreatePayload({
|
|
||||||
taskId: createdTask.id,
|
|
||||||
reservationId: reservation.reservationId,
|
|
||||||
sourceNodeId: nodeIdForReservation,
|
|
||||||
createdAt: createdTask.createdAt,
|
|
||||||
updatedAt: createdTask.updatedAt,
|
|
||||||
prompt: (await scopedStore.getTask(createdTask.id)).prompt,
|
|
||||||
createInput: toReplicatedCreateInput(createdTask),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const peer of remoteNodes) {
|
|
||||||
await fetchFromRemoteNode(peer, "/api/mesh/tasks/create", {
|
|
||||||
method: "POST",
|
|
||||||
body: replicatedPayload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await allocator.commitDistributedTaskIdReservation({
|
|
||||||
reservationId: reservation.reservationId,
|
|
||||||
nodeId: nodeIdForReservation,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(createdTask);
|
|
||||||
return;
|
|
||||||
} catch (err: unknown) {
|
|
||||||
await allocator.abortDistributedTaskIdReservation({
|
|
||||||
reservationId: reservation.reservationId,
|
|
||||||
nodeId: nodeIdForReservation,
|
|
||||||
reason: "failed-create",
|
|
||||||
}).catch(() => undefined);
|
|
||||||
|
|
||||||
if (createdTask) {
|
|
||||||
await scopedStore.deleteTask(createdTask.id).catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < maxCreateAttempts && isOverlapClassFailure(err)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
throw new ApiError(503, `Cluster task create failed: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
Reference in New Issue
Block a user