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:
Fusion
2026-05-07 00:05:34 -07:00
committed by gsxdsm
parent 1a0124c5da
commit d1490d2972
18 changed files with 887 additions and 51 deletions

View 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);
});
});

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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,

View 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,
};
}

View File

@@ -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);

View File

@@ -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;