feat(FN-3450): add mesh task create replication across nodes
Implements distributed mesh task creation by adding replicated create primitives to the core store, wiring new API routes (`register-mesh-routes.ts`) that replicate task creation across clustered nodes while preserving remote-targeting metadata, and updating the dashboard's task creation flow accord Fusion-Task-Id: FN-3450
This commit is contained in:
107
packages/core/src/__tests__/mesh-task-replication.test.ts
Normal file
107
packages/core/src/__tests__/mesh-task-replication.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBootstrapPrompt,
|
||||
buildMeshReplicatedTaskCreatePayload,
|
||||
taskMatchesReplicatedCreate,
|
||||
toReplicatedCreateInput,
|
||||
} from "../mesh-task-replication.js";
|
||||
|
||||
describe("mesh-task-replication", () => {
|
||||
it("buildBootstrapPrompt matches task bootstrap format", () => {
|
||||
expect(buildBootstrapPrompt("FN-1", undefined, "desc")).toBe("# FN-1\n\ndesc\n");
|
||||
expect(buildBootstrapPrompt("FN-1", "Title", "desc")).toBe("# FN-1: Title\n\ndesc\n");
|
||||
});
|
||||
|
||||
it("buildMeshReplicatedTaskCreatePayload includes canonical fields", () => {
|
||||
const payload = buildMeshReplicatedTaskCreatePayload({
|
||||
taskId: "FN-100",
|
||||
reservationId: "res-100",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-100\n\nhello\n",
|
||||
createInput: { description: "hello" },
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-100",
|
||||
taskId: "FN-100",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-100\n\nhello\n",
|
||||
input: { description: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
it("toReplicatedCreateInput preserves node targeting and source metadata", () => {
|
||||
const input = toReplicatedCreateInput({
|
||||
id: "FN-300",
|
||||
title: "Task",
|
||||
description: "hello",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
breakIntoSubtasks: false,
|
||||
enabledWorkflowSteps: [],
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
nodeId: "node-z",
|
||||
priority: "normal",
|
||||
sourceType: "agent",
|
||||
sourceAgentId: "agent-1",
|
||||
sourceRunId: "run-1",
|
||||
sourceSessionId: "session-1",
|
||||
sourceMessageId: "msg-1",
|
||||
sourceParentTaskId: "FN-100",
|
||||
sourceMetadata: { foo: "bar" },
|
||||
} as any);
|
||||
|
||||
expect(input.nodeId).toBe("node-z");
|
||||
expect(input.source?.sourceType).toBe("agent");
|
||||
expect(input.source?.sourceAgentId).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("taskMatchesReplicatedCreate validates equivalence", () => {
|
||||
const existing = {
|
||||
id: "FN-200",
|
||||
title: undefined,
|
||||
description: "hello",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
breakIntoSubtasks: false,
|
||||
enabledWorkflowSteps: [],
|
||||
priority: "normal",
|
||||
sourceType: "unknown",
|
||||
sourceAgentId: undefined,
|
||||
sourceRunId: undefined,
|
||||
sourceSessionId: undefined,
|
||||
sourceMessageId: undefined,
|
||||
sourceParentTaskId: undefined,
|
||||
sourceMetadata: undefined,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-200\n\nhello\n",
|
||||
} as const;
|
||||
|
||||
const payload = {
|
||||
replicationVersion: 1 as const,
|
||||
reservationId: "res-200",
|
||||
taskId: "FN-200",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-200\n\nhello\n",
|
||||
input: { description: "hello", column: "triage" as const },
|
||||
};
|
||||
|
||||
expect(taskMatchesReplicatedCreate(existing as any, payload)).toBe(true);
|
||||
expect(taskMatchesReplicatedCreate(existing as any, { ...payload, prompt: "# FN-200\n\nbye\n" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11356,6 +11356,91 @@ describe("RunMutationContext", () => {
|
||||
const second = store.getDistributedTaskIdAllocator();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("createTaskWithReservedId creates using provided id", async () => {
|
||||
const created = await store.createTaskWithReservedId(
|
||||
{ description: "replicated task", nodeId: "node-b" },
|
||||
{ taskId: "FN-9001" },
|
||||
);
|
||||
|
||||
expect(created.id).toBe("FN-9001");
|
||||
expect(created.nodeId).toBe("node-b");
|
||||
const detail = await store.getTask("FN-9001");
|
||||
expect(detail.prompt).toBe("# FN-9001\n\nreplicated task\n");
|
||||
});
|
||||
|
||||
it("createTaskWithReservedId rejects duplicates and self-dependencies", async () => {
|
||||
await store.createTaskWithReservedId({ description: "first" }, { taskId: "FN-9003" });
|
||||
|
||||
await expect(
|
||||
store.createTaskWithReservedId({ description: "duplicate" }, { taskId: "FN-9003" }),
|
||||
).rejects.toThrow("Task ID already exists: FN-9003");
|
||||
|
||||
await expect(
|
||||
store.createTaskWithReservedId(
|
||||
{ description: "self dep", dependencies: ["FN-9004"] },
|
||||
{ taskId: "FN-9004" },
|
||||
),
|
||||
).rejects.toThrow("Task FN-9004 cannot depend on itself");
|
||||
});
|
||||
|
||||
it("applyReplicatedTaskCreate does not auto-apply default workflow steps", async () => {
|
||||
const workflowStep = await store.createWorkflowStep({
|
||||
name: "Default step",
|
||||
description: "auto",
|
||||
enabled: true,
|
||||
defaultOn: true,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
replicationVersion: 1 as const,
|
||||
reservationId: "res-default-step",
|
||||
taskId: "FN-9010",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-9010\n\ncluster create\n",
|
||||
input: {
|
||||
description: "cluster create",
|
||||
column: "triage" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await store.applyReplicatedTaskCreate(payload);
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.task.enabledWorkflowSteps).toBeUndefined();
|
||||
expect(workflowStep.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("applyReplicatedTaskCreate is idempotent and detects collisions", async () => {
|
||||
const payload = {
|
||||
replicationVersion: 1 as const,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-9002",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-9002\n\ncluster create\n",
|
||||
input: {
|
||||
description: "cluster create",
|
||||
column: "triage" as const,
|
||||
nodeId: "node-c",
|
||||
},
|
||||
};
|
||||
|
||||
const first = await store.applyReplicatedTaskCreate(payload);
|
||||
expect(first.applied).toBe(true);
|
||||
const second = await store.applyReplicatedTaskCreate(payload);
|
||||
expect(second.applied).toBe(false);
|
||||
expect(second.task.id).toBe("FN-9002");
|
||||
|
||||
await expect(
|
||||
store.applyReplicatedTaskCreate({
|
||||
...payload,
|
||||
input: { ...payload.input, description: "different" },
|
||||
}),
|
||||
).rejects.toThrow("Replicated task payload collision");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FTS5 corruption recovery during upsert", () => {
|
||||
|
||||
@@ -938,10 +938,11 @@ export class Database {
|
||||
// and there's no other writer to coordinate with — so we skip WAL-only
|
||||
// tuning there.
|
||||
if (!inMemory) {
|
||||
// Wait up to 5s for locks to clear before returning SQLITE_BUSY.
|
||||
// Set this before other PRAGMAs so they also benefit from lock waiting.
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
// Enable WAL mode for concurrent reader/writer access
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
// In WAL mode NORMAL is nearly as durable as FULL with much lower fsync cost.
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
// Checkpoint every 100 pages (~400 KB) to keep WAL small and reduce
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, PROJECT_AUTH_ROLES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, ProjectAuthRole, ProjectAuthUser, ProjectAuthMembership, ProjectAuthProvider, ProjectAuthSession, ProjectAuthUserCreateInput, ProjectAuthMembershipCreateInput, ProjectAuthProviderCreateInput, ProjectAuthSessionCreateInput, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, ProjectAuthRole, ProjectAuthUser, ProjectAuthMembership, ProjectAuthProvider, ProjectAuthSession, ProjectAuthUserCreateInput, ProjectAuthMembershipCreateInput, ProjectAuthProviderCreateInput, ProjectAuthSessionCreateInput, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export * from "./mesh-replication-protocol.js";
|
||||
export * from "./mesh-task-replication.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
resolveAgentPrompt,
|
||||
|
||||
169
packages/core/src/mesh-task-replication.ts
Normal file
169
packages/core/src/mesh-task-replication.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { MeshReplicatedTaskCreatePayload, Task, TaskCreateInput, TaskDetail, TaskSource } from "./types.js";
|
||||
|
||||
export function buildBootstrapPrompt(taskId: string, title: string | undefined, description: string): string {
|
||||
const heading = title ? `${taskId}: ${title}` : taskId;
|
||||
return `# ${heading}\n\n${description}\n`;
|
||||
}
|
||||
|
||||
export function buildMeshReplicatedTaskCreatePayload(input: {
|
||||
taskId: string;
|
||||
reservationId: string;
|
||||
sourceNodeId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
prompt: string;
|
||||
createInput: TaskCreateInput;
|
||||
}): MeshReplicatedTaskCreatePayload {
|
||||
return {
|
||||
replicationVersion: 1,
|
||||
reservationId: input.reservationId,
|
||||
taskId: input.taskId,
|
||||
sourceNodeId: input.sourceNodeId,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
prompt: input.prompt,
|
||||
input: input.createInput,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneUndefined<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => pruneUndefined(entry)) as T;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entry]) => entry !== undefined)
|
||||
.map(([key, entry]) => [key, pruneUndefined(entry)]);
|
||||
return Object.fromEntries(entries) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeCreateInput(input: TaskCreateInput): TaskCreateInput {
|
||||
const source = input.source;
|
||||
return pruneUndefined({
|
||||
...input,
|
||||
column: input.column ?? "triage",
|
||||
source: source
|
||||
? {
|
||||
...source,
|
||||
sourceType: source.sourceType ?? "unknown",
|
||||
}
|
||||
: { sourceType: "unknown" as const },
|
||||
dependencies: input.dependencies ?? [],
|
||||
enabledWorkflowSteps: input.enabledWorkflowSteps ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function toTaskSource(source: Omit<TaskSource, "sourceType"> & { sourceType?: TaskSource["sourceType"] }): TaskSource {
|
||||
return {
|
||||
...source,
|
||||
sourceType: source.sourceType ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function isSubsetEqual(expected: unknown, actual: unknown): boolean {
|
||||
if (Array.isArray(expected)) {
|
||||
return Array.isArray(actual)
|
||||
&& expected.length === actual.length
|
||||
&& expected.every((entry, index) => isSubsetEqual(entry, actual[index]));
|
||||
}
|
||||
if (expected && typeof expected === "object") {
|
||||
if (!actual || typeof actual !== "object") return false;
|
||||
const expectedRecord = expected as Record<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return Object.entries(expectedRecord).every(([key, value]) => isSubsetEqual(value, actualRecord[key]));
|
||||
}
|
||||
return Object.is(expected, actual);
|
||||
}
|
||||
|
||||
export function taskMatchesReplicatedCreate(existing: TaskDetail, payload: MeshReplicatedTaskCreatePayload): boolean {
|
||||
const existingPrompt = existing.prompt;
|
||||
const existingCreateInput: TaskCreateInput = {
|
||||
title: existing.title,
|
||||
description: existing.description,
|
||||
column: existing.column,
|
||||
dependencies: existing.dependencies,
|
||||
breakIntoSubtasks: existing.breakIntoSubtasks,
|
||||
enabledWorkflowSteps: existing.enabledWorkflowSteps,
|
||||
modelPresetId: existing.modelPresetId,
|
||||
modelProvider: existing.modelProvider,
|
||||
modelId: existing.modelId,
|
||||
validatorModelProvider: existing.validatorModelProvider,
|
||||
validatorModelId: existing.validatorModelId,
|
||||
planningModelProvider: existing.planningModelProvider,
|
||||
planningModelId: existing.planningModelId,
|
||||
thinkingLevel: existing.thinkingLevel,
|
||||
missionId: existing.missionId,
|
||||
sliceId: existing.sliceId,
|
||||
assignedAgentId: existing.assignedAgentId,
|
||||
nodeId: existing.nodeId,
|
||||
assigneeUserId: existing.assigneeUserId,
|
||||
reviewLevel: existing.reviewLevel,
|
||||
executionMode: existing.executionMode,
|
||||
priority: existing.priority,
|
||||
sourceIssue: existing.sourceIssue,
|
||||
source: toTaskSource({
|
||||
sourceType: existing.sourceType,
|
||||
sourceAgentId: existing.sourceAgentId,
|
||||
sourceRunId: existing.sourceRunId,
|
||||
sourceSessionId: existing.sourceSessionId,
|
||||
sourceMessageId: existing.sourceMessageId,
|
||||
sourceParentTaskId: existing.sourceParentTaskId,
|
||||
sourceMetadata: existing.sourceMetadata,
|
||||
}),
|
||||
baseBranch: existing.baseBranch,
|
||||
branch: existing.branch,
|
||||
};
|
||||
|
||||
return (
|
||||
existing.id === payload.taskId &&
|
||||
existing.createdAt === payload.createdAt &&
|
||||
existing.updatedAt === payload.updatedAt &&
|
||||
existingPrompt === payload.prompt &&
|
||||
isSubsetEqual(normalizeCreateInput(payload.input), normalizeCreateInput(existingCreateInput))
|
||||
);
|
||||
}
|
||||
|
||||
export function replicationCollisionError(taskId: string): Error {
|
||||
return new Error(`Replicated task payload collision for existing task ${taskId}`);
|
||||
}
|
||||
|
||||
export function toReplicatedCreateInput(task: Task): TaskCreateInput {
|
||||
return {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
column: task.column,
|
||||
dependencies: task.dependencies,
|
||||
breakIntoSubtasks: task.breakIntoSubtasks,
|
||||
enabledWorkflowSteps: task.enabledWorkflowSteps,
|
||||
modelPresetId: task.modelPresetId,
|
||||
modelProvider: task.modelProvider,
|
||||
modelId: task.modelId,
|
||||
validatorModelProvider: task.validatorModelProvider,
|
||||
validatorModelId: task.validatorModelId,
|
||||
planningModelProvider: task.planningModelProvider,
|
||||
planningModelId: task.planningModelId,
|
||||
thinkingLevel: task.thinkingLevel,
|
||||
missionId: task.missionId,
|
||||
sliceId: task.sliceId,
|
||||
assignedAgentId: task.assignedAgentId,
|
||||
nodeId: task.nodeId,
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
reviewLevel: task.reviewLevel,
|
||||
executionMode: task.executionMode,
|
||||
priority: task.priority,
|
||||
sourceIssue: task.sourceIssue,
|
||||
source: toTaskSource({
|
||||
sourceType: task.sourceType,
|
||||
sourceAgentId: task.sourceAgentId,
|
||||
sourceRunId: task.sourceRunId,
|
||||
sourceSessionId: task.sourceSessionId,
|
||||
sourceMessageId: task.sourceMessageId,
|
||||
sourceParentTaskId: task.sourceParentTaskId,
|
||||
sourceMetadata: task.sourceMetadata,
|
||||
}),
|
||||
baseBranch: task.baseBranch,
|
||||
branch: task.branch,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,12 @@ import { validateNodeOverrideChange } from "./node-override-guard.js";
|
||||
import { sanitizeTitle } from "./ai-summarize.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
import { createDistributedTaskIdAllocator, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
||||
import {
|
||||
buildBootstrapPrompt,
|
||||
replicationCollisionError,
|
||||
taskMatchesReplicatedCreate,
|
||||
} from "./mesh-task-replication.js";
|
||||
import type { MeshReplicatedTaskApplyResult, MeshReplicatedTaskCreatePayload } from "./types.js";
|
||||
|
||||
/** Database row shape for the tasks table (all columns). */
|
||||
interface TaskRow {
|
||||
@@ -253,16 +259,6 @@ function compactTaskActivityLog(entries: TaskLogEntry[]): TaskLogEntry[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact PROMPT.md bytes that `createTask` writes for a triage task.
|
||||
* Single source of truth so the stub-detection comparison below stays in sync
|
||||
* with the bootstrap shape.
|
||||
*/
|
||||
function buildBootstrapPrompt(taskId: string, title: string | undefined, description: string): string {
|
||||
const heading = title ? `${taskId}: ${title}` : taskId;
|
||||
return `# ${heading}\n\n${description}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a PROMPT.md body is the auto-generated bootstrap stub
|
||||
* (`# heading\n\n<description>\n`) that `createTask` writes for triage tasks,
|
||||
@@ -2287,6 +2283,87 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return task;
|
||||
}
|
||||
|
||||
async createTaskWithReservedId(
|
||||
input: TaskCreateInput,
|
||||
options: {
|
||||
taskId: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
prompt?: string;
|
||||
applyDefaultWorkflowSteps?: boolean;
|
||||
},
|
||||
): Promise<Task> {
|
||||
if (!input.description?.trim()) {
|
||||
throw new Error("Description is required and cannot be empty");
|
||||
}
|
||||
|
||||
const id = options.taskId.trim();
|
||||
if (!id) {
|
||||
throw new Error("taskId is required");
|
||||
}
|
||||
|
||||
if (input.dependencies?.includes(id)) {
|
||||
throw new Error(`Task ${id} cannot depend on itself`);
|
||||
}
|
||||
|
||||
if (this.readTaskFromDb(id)) {
|
||||
throw new Error(`Task ID already exists: ${id}`);
|
||||
}
|
||||
|
||||
const title = input.title?.trim() || undefined;
|
||||
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
|
||||
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
|
||||
: undefined;
|
||||
|
||||
if (input.enabledWorkflowSteps === undefined && options.applyDefaultWorkflowSteps !== false) {
|
||||
try {
|
||||
const allSteps = await this.listWorkflowSteps();
|
||||
const defaultOnSteps = allSteps
|
||||
.filter((ws) => ws.enabled && ws.defaultOn)
|
||||
.map((ws) => ws.id);
|
||||
if (defaultOnSteps.length > 0) {
|
||||
resolvedWorkflowSteps = defaultOnSteps;
|
||||
}
|
||||
} catch (err) {
|
||||
storeLog.warn("Failed to auto-apply default workflow steps during reserved task creation; auto-defaulting skipped", {
|
||||
phase: "createTaskWithReservedId:workflow-auto-default",
|
||||
skippedAutoDefaulting: true,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
descriptionLength: input.description.length,
|
||||
});
|
||||
}
|
||||
} else if (Array.isArray(input.enabledWorkflowSteps) && input.enabledWorkflowSteps.length === 0) {
|
||||
resolvedWorkflowSteps = undefined;
|
||||
}
|
||||
|
||||
return this._createTaskInternal(input, title, resolvedWorkflowSteps, id, {
|
||||
createdAt: options.createdAt,
|
||||
updatedAt: options.updatedAt,
|
||||
promptOverride: options.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
async applyReplicatedTaskCreate(payload: MeshReplicatedTaskCreatePayload): Promise<MeshReplicatedTaskApplyResult> {
|
||||
const existing = this.readTaskFromDb(payload.taskId);
|
||||
if (existing) {
|
||||
const existingDetail = await this.getTask(payload.taskId);
|
||||
if (taskMatchesReplicatedCreate(existingDetail, payload)) {
|
||||
return { task: existingDetail, applied: false };
|
||||
}
|
||||
throw replicationCollisionError(payload.taskId);
|
||||
}
|
||||
|
||||
const task = await this.createTaskWithReservedId(payload.input, {
|
||||
taskId: payload.taskId,
|
||||
createdAt: payload.createdAt,
|
||||
updatedAt: payload.updatedAt,
|
||||
prompt: payload.prompt,
|
||||
applyDefaultWorkflowSteps: false,
|
||||
});
|
||||
|
||||
return { task, applied: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper for task creation. Used by createTask() and potentially other
|
||||
* internal methods that need to create tasks without triggering summarization.
|
||||
@@ -2295,9 +2372,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
input: TaskCreateInput,
|
||||
title: string | undefined,
|
||||
resolvedWorkflowSteps: string[] | undefined,
|
||||
id: string
|
||||
id: string,
|
||||
options?: {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
promptOverride?: string;
|
||||
},
|
||||
): Promise<Task> {
|
||||
const now = new Date().toISOString();
|
||||
const now = options?.createdAt ?? new Date().toISOString();
|
||||
const task: Task = {
|
||||
id,
|
||||
title,
|
||||
@@ -2338,7 +2420,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
log: [{ timestamp: now, action: "Task created" }],
|
||||
columnMovedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
updatedAt: options?.updatedAt ?? now,
|
||||
};
|
||||
|
||||
const dir = this.taskDir(id);
|
||||
@@ -2348,9 +2430,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// Update cache if watcher is active
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
|
||||
const prompt = task.column === "triage"
|
||||
? buildBootstrapPrompt(id, task.title, task.description)
|
||||
: this.generateSpecifiedPrompt(task);
|
||||
const prompt = options?.promptOverride
|
||||
?? (task.column === "triage"
|
||||
? buildBootstrapPrompt(id, task.title, task.description)
|
||||
: this.generateSpecifiedPrompt(task));
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "PROMPT.md"), prompt);
|
||||
|
||||
|
||||
@@ -1154,6 +1154,22 @@ export interface TaskCreateInput {
|
||||
|
||||
// ── Todo List Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface MeshReplicatedTaskCreatePayload {
|
||||
replicationVersion: 1;
|
||||
reservationId: string;
|
||||
taskId: string;
|
||||
sourceNodeId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
prompt: string;
|
||||
input: TaskCreateInput;
|
||||
}
|
||||
|
||||
export interface MeshReplicatedTaskApplyResult {
|
||||
task: Task;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export interface TodoList {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
||||
Reference in New Issue
Block a user