Files
fusion/packages/core/src/task-store/task-creation.ts

1212 lines
62 KiB
TypeScript

/**
* task-creation operations.
*
* FNXC:StoreModularization 2026-06-25-00:00:
* Extracted from the monolithic packages/core/src/store.ts as a pure
* behavior-preserving refactor. Each function receives the TaskStore
* instance as its first parameter and performs byte-identical work.
*/
import {TaskStore, storeLog} from "../store.js";
import {InvalidFileScopeError, SelfDefeatingDependencyError, detectSelfDefeatingDependency, TombstonedTaskResurrectionError} from "./errors.js";
import {mkdir, rename, rm, writeFile} from "node:fs/promises";
import {join} from "node:path";
import {existsSync} from "node:fs";
import {randomUUID} from "node:crypto";
import type {Task, TaskCreateInput, Settings} from "../types.js";
import "../builtin-traits.js";
import {applyReviewLevelPreset} from "../review-level-preset.js";
import {normalizeTaskPriority} from "../task-priority.js";
import {sanitizeTitle, summarizeTitle} from "../ai-summarize.js";
import {extractTaskIdTokens, normalizeTitleForTaskId} from "../task-title-id-drift.js";
import {resolveTitleSummarizerSettingsModel} from "../model-resolution.js";
import {resolveEffectiveSettingsById} from "../workflow-settings-resolver.js";
import {getErrorMessage} from "../error-message.js";
import {generateTaskLineageId} from "../task-lineage.js";
import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicate-intake.js";
import {buildBootstrapPrompt} from "../mesh-task-replication.js";
import {resolveWorkflowIrById} from "../workflow-ir-resolver.js";
import {resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.js";
import type {WorkflowIr} from "../workflow-ir-types.js";
import {DEFAULT_WORKFLOW_ID} from "../builtin-workflows.js";
import {columnsWithFlag} from "../workflow-lifecycle-traits.js";
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js";
import {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js";
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js";
import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js";
import type {DbTransaction} from "../postgres/data-layer.js";
import { resolveTaskPrefix } from "./task-prefix.js";
type CreateTaskWithAfterInsert = TaskCreateInput & {
/** Internal transaction hook; never persisted in task source metadata. */
afterTaskInsert?: (tx: DbTransaction, task: Task) => Promise<void>;
/**
* Internal bootstrap escape hatch. The caller supplies an equivalent
* transactionally-safe duplicate reconciliation after the feature claim.
*/
skipSameAgentDuplicateIntake?: boolean;
};
function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
/*
FNXC:EphemeralAgentTaskCreation 2026-07-30-19:10:
The legacy SQLite store remains a supported MessageStore/task-materialization
backend. Its durable partial unique index is the same at-most-once anchor as
PostgreSQL: release/reclaim reuses one stable key, so concurrent creators can
only insert one task and the loser returns that persisted task.
*/
const columns = store.db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "proposalClaimId")) {
store.db.exec("ALTER TABLE tasks ADD COLUMN proposalClaimId TEXT");
}
store.db.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_tasks_proposal_claim_id ON tasks(proposalClaimId) WHERE proposalClaimId IS NOT NULL",
);
}
/*
FNXC:MergedPlanningColumn 2026-07-28-12:55 (U11 precondition):
The intake column used to be resolved ONLY as a by-product of materializing workflow steps, so a
create that supplied `enabledWorkflowSteps` without an explicit `workflowId` took neither
materialization branch and fell through to the hard-coded `|| "triage"`. On Coding (Ideas) that
lands the card in a column the workflow does not declare — a phantom lane, today.
U11 makes it the DEFAULT workflow's problem: once `triage` is deleted, every create down this path
lands in an undeclared column AND (because `isIntakeColumn` keys on the same literal) gets
`generateSpecifiedPrompt` instead of the bootstrap seed. Triage admits a card for planning only
when its PROMPT.md reads as a seed, so the card would sit in Planning forever with no log line in
any lane — FN-8587's failure mode, for every new card.
Resolution is deliberately SIDE-EFFECT-FREE: it reads the default workflow's IR and asks which
column carries `intake`. It must not call `materializeDefaultWorkflowSteps`, which persists step
rows the caller explicitly opted out of by supplying its own `enabledWorkflowSteps`.
Unresolvable workflow returns undefined and the caller keeps its existing legacy fallback.
*/
/*
FNXC:MergedPlanningColumn 2026-07-30-10:20 (Phase B — task-creation.ts to zero column literals):
The two facts intake classification actually needs, resolved from the IR instead of compared against
`"triage"`:
intake — which column this workflow intakes into
manual — whether that intake carries `autoTriage: false`, i.e. an operator must promote the card
`manual` is what the `!== "triage"` comparison was really reaching for. It distinguished
"manual-intake workflow" (Coding (Ideas)) from "the legacy default" by naming the default's column
id — which stops meaning anything once the default's intake IS `todo`. The trait config states it
directly and survives any rename or merge.
Unresolvable workflow returns `{ manual: false }` with no intake, so callers keep their existing
conservative behavior rather than acting on a guess.
*/
/*
FNXC:MergedPlanningColumn 2026-07-31-22:30 (missed creation surfaces):
Exported so refine (`update-task-deps.ts`) and duplicate (`project-store-ops.ts`) resolve the same
intake lane as the main create. Both built Task rows directly inside `createTaskWithId` callbacks
with a hardcoded `column: "triage"` — a column the merged coding workflow no longer declares — so a
refined/duplicated card landed in an UNDECLARED column: rendered with the legacy amber badge,
invisible to trait-driven sweeps until the undeclared-column re-home rescued it. #2589/#2603 fixed
the main create; these two surfaces were the enumeration gap (found via a two-tone Planning badge on
the live board).
*/
export async function resolveWorkflowIntakeFacts(
store: TaskStore,
workflowIdOverride?: string,
): Promise<{ intake?: string; hold?: string; manual: boolean }> {
try {
const workflowId = workflowIdOverride ?? (await store.getDefaultWorkflowId()) ?? DEFAULT_WORKFLOW_ID;
const ir = await resolveWorkflowIrById(store, workflowId);
const intake = columnsWithFlag(ir, "intake")[0];
// The PLANNING column a quick-add Start create lands in — the workflow's hold column, which for
// a merged Planning lane is the same column as intake and is then excluded by the `!==` below.
const hold = columnsWithFlag(ir, "hold")[0];
if (!intake) return { hold, manual: false };
const declared = (ir as { columns?: Array<{ id: string; traits?: Array<{ trait: string; config?: Record<string, unknown> }> }> })
.columns?.find((column) => column.id === intake);
const intakeTrait = declared?.traits?.find((trait) => trait.trait === "intake");
return { intake, hold, manual: intakeTrait?.config?.autoTriage === false };
} catch {
return { manual: false };
}
}
async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise<string | undefined> {
try {
/*
FNXC:MergedPlanningColumn 2026-07-29-14:40 (U11 post-merge audit):
Fall back to DEFAULT_WORKFLOW_ID when no default row is persisted. A fresh project has none —
`builtin:coding` is the IMPLICIT default that every other resolver already uses — so returning
undefined here sent the create to the hard-coded `"triage"` literal, a column the merged
default workflow does not declare.
*/
const workflowId = (await store.getDefaultWorkflowId()) ?? DEFAULT_WORKFLOW_ID;
const ir = await resolveWorkflowIrById(store, workflowId);
return columnsWithFlag(ir, "intake")[0];
} catch {
return undefined;
}
}
export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
// U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins).
input = applyReviewLevelPreset(input);
if (!input.description?.trim()) {
throw new Error("Description is required and cannot be empty");
}
const selfDefeatingDep = detectSelfDefeatingDependency(input.title, input.dependencies ?? []);
if (selfDefeatingDep) {
throw new SelfDefeatingDependencyError(
input.title?.trim() ?? "",
selfDefeatingDep.matchedVerb,
selfDefeatingDep.operandTaskId,
);
}
// Resolve settings (same logic as the SQLite path).
let resolvedSettings = options?.settings;
if (!resolvedSettings) {
try {
resolvedSettings = await store.getSettings();
} catch {
resolvedSettings = {};
}
}
// Resolve title summarizer (same logic as the SQLite path).
let onSummarize = options?.onSummarize;
if (!onSummarize && (resolvedSettings?.autoSummarizeTitles === true || input.summarize === true)) {
let summarizerSettings: Partial<Settings> = resolvedSettings ?? {};
try {
const defaultWorkflowId = (await store.getDefaultWorkflowId()) ?? "builtin:coding";
const effective = await resolveEffectiveSettingsById(
store,
defaultWorkflowId,
store.getWorkflowSettingsProjectId(),
);
summarizerSettings = { ...summarizerSettings, ...(effective as Partial<Settings>) };
} catch {
// Never-throw: fall back to the base settings (global lane only).
}
const summarizerModel = resolveTitleSummarizerSettingsModel(summarizerSettings);
if (summarizerModel.provider && summarizerModel.modelId) {
onSummarize = async (description: string) => {
try {
return await summarizeTitle(
description,
store.getRootDir(),
summarizerModel.provider,
summarizerModel.modelId,
);
} catch {
return null;
}
};
}
}
const title = input.title?.trim() || undefined;
const shouldSummarize =
!title &&
input.description.length > 200 &&
(input.summarize === true || resolvedSettings?.autoSummarizeTitles === true);
const hasPendingSummarization = shouldSummarize && typeof onSummarize === "function";
const shouldInvokeTaskCreatedHook = options?.invokeTaskCreatedHook !== false;
// Resolve enabledWorkflowSteps (same logic as the SQLite path).
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
? await store.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
: undefined;
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
let resolvedEntryColumn: string | undefined;
/*
FNXC:WorkflowCreation 2026-07-05-14:30:
User-facing task creation can submit a selected workflowId and optional-group
toggles together. The visible workflow selection is operator intent and must
persist as task_workflow_selection; enabledWorkflowSteps only overrides that
workflow's default optional-group seed. Mirrors the SQLite-path fix
(FNXC:WorkflowCreation 2026-06-28-23:09) that these PostgreSQL-cutover copies
predated: previously a create submitting BOTH workflowId and
enabledWorkflowSteps silently skipped the selection row.
*/
const explicitWorkflowId = input.workflowId;
if (explicitWorkflowId !== undefined) {
if (explicitWorkflowId === null) {
// Explicit "No workflow": skip default materialization entirely.
resolvedWorkflowSteps = undefined;
} else {
// Compile + materialize up front so unknown/fragment ids throw BEFORE
// the task row is created (no orphaned steps, no half-created task).
const selected = await store.materializeExplicitWorkflowSteps(explicitWorkflowId);
const explicitStepIds = input.enabledWorkflowSteps !== undefined
? (resolvedWorkflowSteps ?? [])
: undefined;
resolvedWorkflowSteps = explicitStepIds ?? selected.stepIds;
resolvedEntryColumn = selected.entryColumnId;
pendingWorkflowSelection = {
workflowId: selected.workflowId,
stepIds: explicitStepIds ?? selected.stepIds,
};
}
} else if (input.enabledWorkflowSteps === undefined) {
try {
const inherited = await store.materializeDefaultWorkflowSteps();
if (inherited) {
resolvedWorkflowSteps = inherited.stepIds;
resolvedEntryColumn = inherited.entryColumnId;
pendingWorkflowSelection = inherited;
}
} catch (err) {
storeLog.warn("Failed to apply default workflow during task creation; falling back to default-on steps", {
phase: "createTaskBackend:default-workflow",
error: err instanceof Error ? err.message : String(err),
});
}
if (resolvedWorkflowSteps === undefined) {
try {
const allSteps = await store.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 task creation; auto-defaulting skipped", {
phase: "createTaskBackend:workflow-auto-default",
skippedAutoDefaulting: true,
error: err instanceof Error ? err.message : String(err),
descriptionLength: input.description.length,
});
}
}
} else {
// Caller supplied its own optional-step toggles, so no materialization branch ran and
// `resolvedEntryColumn` was left undefined — the gap that dropped the card into the
// hard-coded `|| "triage"`. The toggles are the caller's; the INTAKE COLUMN is still the
// project default workflow's, so resolve it without materializing steps.
resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store);
if (input.enabledWorkflowSteps.length === 0) {
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
// optional-step selection must hydrate back as [], not undefined.
resolvedWorkflowSteps = [];
}
}
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:20:
// Allocator reservation: use the async DistributedTaskIdAllocator which
// is now wired for backend mode. It reserves the next task ID against
// PostgreSQL's distributed_task_id tables. On success it commits; on
// failure it aborts the reservation so the sequence is not wasted.
const allocator = store.getDistributedTaskIdAllocator();
const settings = await store.getSettingsFast();
// FNXC:MissionTaskPrefix 2026-07-26-12:00: backend task creation must honor the transient mission prefix hint before project settings and the KB fallback.
const prefix = resolveTaskPrefix(input.taskPrefix, settings.taskPrefix, "KB");
const nodeId = await store.resolveLocalNodeIdForTaskAllocation();
const reservation = await allocator.reserveDistributedTaskId({
prefix,
nodeId,
});
let task: Task;
try {
await store.assertNoDependencyCycle(reservation.taskId, input.dependencies ?? [], "createTask");
task = await store._createTaskInternalBackend(
input,
title,
resolvedWorkflowSteps,
reservation.taskId,
{ invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization, resolvedEntryColumn, onProposalClaimConflict: options?.onProposalClaimConflict },
);
await allocator.commitDistributedTaskIdReservation({
reservationId: reservation.reservationId,
nodeId,
});
} catch (err) {
await allocator.abortDistributedTaskIdReservation({
reservationId: reservation.reservationId,
nodeId,
reason: "failed-create",
}).catch(() => undefined);
throw err;
}
// Record the inherited workflow selection now that the task row exists.
if (pendingWorkflowSelection) {
try {
await store.writeTaskWorkflowSelection(task.id, pendingWorkflowSelection.workflowId, pendingWorkflowSelection.stepIds);
} catch (err) {
storeLog.warn("Failed to record inherited workflow selection", {
taskId: task.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
// Deferred title summarization (same fire-and-forget pattern as SQLite path).
if (hasPendingSummarization && shouldInvokeTaskCreatedHook) {
const id = task.id;
Promise.resolve().then(async () => {
try {
const generatedTitle = await onSummarize!(input.description);
const sanitizedTitle = sanitizeTitle(generatedTitle);
if (sanitizedTitle) {
await store.trackDeferredTaskCreatedWork(async () => {
if (store.closing) return;
const currentTask = await store.getTask(id);
if (currentTask && !currentTask.title) {
const normalizedTitle = normalizeTitleForTaskId(sanitizedTitle, id);
if (normalizedTitle.title && !store.closing) {
await store.updateTask(id, { title: normalizedTitle.title });
}
}
});
}
} catch (err) {
storeLog.warn(
`Title summarization failed for task ${id}: ${err instanceof Error ? err.message : String(err)}`,
{ taskId: id, descriptionLength: input.description.length },
);
}
await store.trackDeferredTaskCreatedWork(async () => {
if (store.closing) return;
let latestTask = task;
try {
const refreshed = await store.getTask(id);
if (refreshed) latestTask = refreshed;
} catch {
// Best-effort refresh; fall back to original task snapshot.
}
if (store.closing) return;
try {
await store.invokeTaskCreatedHook(latestTask);
} catch (err) {
storeLog.warn("Deferred task-created hook failed", {
taskId: id,
error: err instanceof Error ? err.message : String(err),
});
}
});
}).catch((err) => {
storeLog.error("Unexpected title summarization promise-chain failure", {
taskId: id,
descriptionLength: input.description.length,
error: err instanceof Error ? err.message : String(err),
});
});
}
return task;
}
export async function _createTaskInternalBackendImpl(store: TaskStore, input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
const layer = store.asyncLayer!;
const now = options?.createdAt ?? new Date().toISOString();
const normalizedTitle = normalizeTitleForTaskId(title, id);
/*
FNXC:MergedPlanningColumn 2026-07-29-14:30 (U11 post-merge audit):
A project that has never explicitly set a default workflow has no persisted default row, so
`materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and
the row below fell through to the hard-coded `|| "triage"`. That column no longer exists in the
default workflow, so the card landed in a lane its own workflow does not declare: triage
discovery resolves intake BY TRAIT and never admits it, hold-release ignores it, and only
`reconcileUndeclaredTaskColumns` eventually re-homes it.
This is the OUT-OF-THE-BOX shape — `builtin:coding` is the IMPLICIT default via
DEFAULT_WORKFLOW_ID and nothing writes a default-workflow row until an operator picks one — so
it affected every new task on a fresh project rather than an edge case.
Resolved side-effect-free and ONLY as a last resort before the literal, so every path that
already has an explicit column or a resolved entry column is untouched. The literal survives as
the final fallback for a store that cannot resolve any workflow at all.
*/
// `workflowId: null` is an explicit "No workflow" opt-out — there is no workflow whose intake
// column could be resolved, so that path keeps the legacy literal.
const fallbackIntakeColumn = (input.column || options?.resolvedEntryColumn || input.workflowId === null)
? undefined
: await resolveDefaultWorkflowIntakeColumn(store);
/* Intake column + whether that intake is MANUAL (autoTriage:false), resolved from the IR. */
const intakeFacts = input.workflowId === null
? { manual: false as boolean, intake: undefined as string | undefined, hold: undefined as string | undefined }
: await resolveWorkflowIntakeFacts(store, input.workflowId ?? undefined);
const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride);
const task: Task = {
id,
lineageId: input.lineageId ?? generateTaskLineageId(),
proposalClaimId: input.proposalClaimId,
title: normalizedTitle.title ?? undefined,
description: input.description,
priority: normalizeTaskPriority(input.priority),
tokenUsage: input.tokenUsage,
declaredSymbols,
sourceIssue: input.sourceIssue,
githubTracking: input.githubTracking,
gitlabTracking: input.gitlabTracking,
sourceType: input.source?.sourceType ?? "unknown",
sourceAgentId: input.source?.sourceAgentId,
sourceRunId: input.source?.sourceRunId,
sourceSessionId: input.source?.sourceSessionId,
sourceMessageId: input.source?.sourceMessageId,
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext),
branchContext: input.branchContext,
autoMerge: input.autoMerge,
autoMergeProvenance: input.autoMerge === undefined ? undefined : "user",
// FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its
// workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when
// no explicit column is given (main FN-7591 parity).
column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
noCommitsExpected: input.noCommitsExpected === true ? true : undefined,
enabledWorkflowSteps: resolvedWorkflowSteps,
modelPresetId: input.modelPresetId,
assignedAgentId: input.assignedAgentId,
assigneeUserId: input.assigneeUserId,
scopeOverride: input.scopeOverride === true ? true : undefined,
scopeOverrideReason: input.scopeOverrideReason,
nodeId: input.nodeId,
modelProvider: input.modelProvider,
modelId: input.modelId,
validatorModelProvider: input.validatorModelProvider,
validatorModelId: input.validatorModelId,
planningModelProvider: input.planningModelProvider,
planningModelId: input.planningModelId,
mergerModelProvider: input.mergerModelProvider,
mergerModelId: input.mergerModelId,
thinkingLevel: input.thinkingLevel,
validatorThinkingLevel: input.validatorThinkingLevel,
planningThinkingLevel: input.planningThinkingLevel,
mergerThinkingLevel: input.mergerThinkingLevel,
reviewLevel: input.reviewLevel,
executionMode: input.executionMode,
// FNXC:PlannerOversight 2026-07-14-18:11: only set when create input is explicit boolean.
sessionAdvisorEnabled: typeof input.sessionAdvisorEnabled === "boolean" ? input.sessionAdvisorEnabled : undefined,
baseBranch: input.baseBranch,
branch: input.branch,
missionId: input.missionId,
sliceId: input.sliceId,
steps: [],
currentStep: 0,
log: [{ timestamp: now, action: "Task created" }],
columnMovedAt: now,
createdAt: now,
updatedAt: options?.updatedAt ?? now,
};
if (normalizedTitle.changed) {
task.log.push({
timestamp: now,
action: "Title normalized: stripped legacy task-id reference",
});
}
const dir = store.taskDir(id);
const stagingDir = `${dir}.creating-${randomUUID()}`;
let ownsStagingDirectory = false;
let ownsPromotedTaskDirectory = false;
const cleanupPreparedTaskFiles = async () => {
if (store.isWatching) store.taskCache.delete(id);
if (ownsStagingDirectory && existsSync(stagingDir)) {
await rm(stagingDir, { recursive: true, force: true });
}
// A rollback after promotion removes only this create's final directory.
// A conflicting existing row never reaches promotion, so its files survive.
if (ownsPromotedTaskDirectory && existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
}
};
/*
FNXC:MissionAdmission 2026-07-23-17:10:
Materialize task files before the transaction that inserts and claims a
defined feature. Filesystem writes cannot join PostgreSQL; this ordering
means a write failure cannot commit a triaged feature pointing at a deleted
task, while the database transaction still makes task insert + feature claim
indivisible.
*/
try {
/*
FNXC:MissionAdmission 2026-07-23-19:00:
PostgreSQL ID/proposal collisions are discovered at row insert, while a
defined-feature bootstrap needs all task artifacts to be writable before
its transaction can commit. Materialize into a unique staging directory;
only the successful insert atomically promotes it to the task directory.
A losing proposal therefore cannot overwrite its winner's task files.
*/
await mkdir(stagingDir, { recursive: true });
ownsStagingDirectory = true;
// Write task.json for backward compatibility and debugging.
if (store.isWatching) store.taskCache.set(id, { ...task });
await store.writeTaskJsonFile(stagingDir, task);
// Write PROMPT.md (same logic as SQLite path).
/*
FNXC:CodingIdeasWorkflow 2026-07-05-19:45:
A freshly created task needs the bootstrap stub only when it lands in a
column the triage service will plan from — the legacy "triage" intake or a
workflow's resolved manual intake (e.g. Coding (Ideas) → "ideas"). Direct
creates into other columns keep generateSpecifiedPrompt (main parity).
*/
/*
FNXC:CodingIdeasWorkflow 2026-07-25-14:20:
Quick-add "Start" collapses create+promote into ONE request: it submits the workflow id AND
the post-intake destination column together, so the card lands in `todo` having never sat in
the workflow's manual intake column. It is still UNPLANNED — nothing has written a spec — but
the intake test above only matched `triage` or the resolved intake column, so the card got
`generateSpecifiedPrompt` instead of the bootstrap seed.
That stranded the card permanently: triage's todo-discovery admits a card only when its
PROMPT.md reads as a seed, so a placeholder spec is classified "already planned" and never
planned, while `generateSpecifiedPrompt` emits hard-coded boilerplate steps ("Implement the
required changes") that no planner ever produced. The card sat in Todo forever with no log
line in any lane. Observed on FN-8587.
Guarded to manual-intake workflows (resolved intake is not the legacy `triage`) landing in the
plan-in-place `todo` column, so the pinned contract for a plain direct create into todo on the
default workflow — which intentionally keeps generateSpecifiedPrompt — is untouched.
*/
/*
FNXC:MergedPlanningColumn 2026-07-30-10:25 (Phase B — task-creation.ts to zero column literals):
Was `resolvedEntryColumn !== "triage"` — naming the DEFAULT workflow's intake id to mean "this
workflow has a MANUAL intake". Post-U11 the default's intake IS `todo`, so that comparison
became vacuously true for the default workflow and the guard stopped distinguishing the two
shapes it exists to separate.
The real fact is the intake trait's `autoTriage: false`, and the real shape is "the card landed
PAST its workflow's manual intake" — which is what quick-add Start does by submitting the
workflow id and the post-intake column together. Both now come from the IR, so the pinned
contract for a plain direct create on a default (auto-triage) workflow is preserved by the
`manual` test rather than by an id coincidence.
*/
const isUnplannedStartCreate = intakeFacts.manual
&& intakeFacts.intake !== undefined
&& intakeFacts.hold !== undefined
&& task.column !== intakeFacts.intake
&& task.column === intakeFacts.hold;
/*
FNXC:MergedPlanningColumn 2026-07-29-14:50 (U11 post-merge audit):
`fallbackIntakeColumn` must be honoured here too. Without it the card lands in the resolved
intake column (correct) but is classified NOT-intake and receives `generateSpecifiedPrompt`
instead of the bootstrap seed — and triage admits a card for planning only when its PROMPT.md
reads as a seed, so it would sit in Planning already looking "planned". That is FN-8587's
failure mode, reached by a different route.
*/
/*
FNXC:MergedPlanningColumn 2026-07-30-10:25 (Phase B):
The leading `task.column === "triage"` was the last-resort clause for a card whose workflow
could not be resolved. `intakeFacts.intake` covers that properly — it falls back to
DEFAULT_WORKFLOW_ID rather than to a bare id — so the literal is gone and an explicit
`column: "triage"` create on a workflow that still declares `triage` is matched through the
resolved intake rather than a coincidence of naming.
*/
const isIntakeColumn = (intakeFacts.intake !== undefined && task.column === intakeFacts.intake)
|| (options?.resolvedEntryColumn !== undefined && task.column === options.resolvedEntryColumn)
|| (fallbackIntakeColumn !== undefined && task.column === fallbackIntakeColumn)
|| isUnplannedStartCreate;
const usedBootstrapPrompt = !options?.promptOverride && isIntakeColumn;
const prompt = options?.promptOverride
?? (isIntakeColumn
? buildBootstrapPrompt(id, task.title, task.description)
: store.generateSpecifiedPrompt(task));
/*
FNXC:FileScopeClassification 2026-07-21-18:05:
Bootstrap intake prompts are freeform descriptions (GitHub issue bodies, paste dumps).
Do not hard-fail create on incidental `## File Scope` tokens in that prose — triage
will write a real planned PROMPT.md later. Strict validation still applies to
promptOverride and generateSpecifiedPrompt paths.
*/
if (!usedBootstrapPrompt) {
const validation = validateFileScopeInPromptContent(prompt);
if (validation.invalid.length > 0) {
throw new InvalidFileScopeError(id, validation.invalid);
}
}
await writeFile(join(stagingDir, "PROMPT.md"), prompt);
} catch (error) {
await cleanupPreparedTaskFiles();
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
throw new Error(`Task ID already exists: ${task.id}`);
}
throw error;
}
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:30:
// Insert the task row via async Drizzle insert inside a transaction.
// A duplicate-ID collision raises a unique_violation (23505) which we
// catch and surface as "Task ID already exists" (matching the SQLite path).
const context = store.createTaskPersistSerializationContext(task);
try {
await layer.transactionImmediate(async (tx) => {
// FNXC:MultiProjectIsolation 2026-07-10: stamp the bound projectId so the
// new task row is attributed to (and later filtered under) this project.
await insertTaskRowInTransaction(tx, task as unknown as Record<string, unknown>, context, layer.projectId);
/*
FNXC:MissionAdmission 2026-07-23-19:00:
The row insert establishes this task as the sole winner before its staged
artifacts replace any stale directory. Promotion remains inside the same
transaction as the defined-feature claim, so a filesystem or claim
failure rolls back the row and cleans only this attempt's files.
*/
if (existsSync(dir)) await rm(dir, { recursive: true, force: true });
await rename(stagingDir, dir);
ownsStagingDirectory = false;
ownsPromotedTaskDirectory = true;
await (input as CreateTaskWithAfterInsert).afterTaskInsert?.(tx, task);
});
} catch (error) {
await cleanupPreparedTaskFiles();
/*
FNXC:EphemeralAgentTaskCreation 2026-07-30-18:30:
Proposal creation retries can race after a creation lease is released while
the original creator is still inserting. Both attempts deliberately use the
same stable proposalClaimId, so the partial unique index is the at-most-once
authority. A 23505 for that key returns the committed winner instead of
treating it as an ID collision; no loser may continue into task-file or
workflow materialization. Other unique violations remain task-ID errors.
*/
if (input.proposalClaimId && isTaskIdConflictError(error)) {
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
if (existing) {
options?.onProposalClaimConflict?.(existing);
return existing;
}
}
if (isTaskIdConflictError(error)) {
throw new Error(`Task ID already exists: ${task.id}`);
}
throw error;
}
/*
FNXC:MissionAdmission 2026-07-23-20:00:
A defined-feature first task has already claimed feature.taskId in the insert
transaction. The ordinary same-agent intake may archive that claimed row
after commit, so this narrow internal opt-out delegates duplicate resolution
to the bootstrap caller, which preserves the claimed canonical atomically.
*/
if (!(input as CreateTaskWithAfterInsert).skipSameAgentDuplicateIntake) {
// Auto-archive dedup (best-effort, same as SQLite path but using async reads).
await store._maybeAutoArchiveSameAgentDuplicateBackend(task, input);
}
store.emitTaskLifecycleEventSafely("task:created", [task]);
if (options?.invokeTaskCreatedHook !== false) {
await store.invokeTaskCreatedHook(task);
}
return task;
}
export async function createTaskImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; }): Promise<Task> {
// U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins).
input = applyReviewLevelPreset(input);
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:10:
// Backend-mode createTask: delegates to createTaskBackend which uses the
// async DistributedTaskIdAllocator (now wired for backend mode) and the
// async insert helper (insertTaskRowInTransaction) to persist the task row
// against PostgreSQL. The file-system operations (PROMPT.md, task.json)
// remain the same. The allocator reservation + commit/abort lifecycle is
// handled by the async allocator against the distributed_task_id tables.
/* FNXC:SqliteDualPathCleanup 2026-07-26-14:08: createTask is PostgreSQL-only. */
return store.createTaskBackend(input, options);
}
export async function createTaskWithReservedIdImpl(store: TaskStore, input: TaskCreateInput, options: { taskId: string; createdAt?: string; updatedAt?: string; prompt?: string; applyDefaultWorkflowSteps?: boolean; invokeTaskCreatedHook?: boolean; },): Promise<Task> {
// U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins).
input = applyReviewLevelPreset(input);
if (!input.description?.trim()) {
throw new Error("Description is required and cannot be empty");
}
const selfDefeatingDep = detectSelfDefeatingDependency(input.title, input.dependencies ?? []);
if (selfDefeatingDep) {
throw new SelfDefeatingDependencyError(
input.title?.trim() ?? "",
selfDefeatingDep.matchedVerb,
selfDefeatingDep.operandTaskId,
);
}
if (input.proposalClaimId) {
ensureSqliteProposalClaimUniqueness(store);
const existing = (await store.listTasks()).find((task) => task.proposalClaimId === input.proposalClaimId);
if (existing) return existing;
}
const id = options.taskId.trim();
if (!id) {
throw new Error("taskId is required");
}
await store.assertNoDependencyCycle(id, input.dependencies ?? [], "createTaskWithReservedId");
await store.maybeResolveTombstonedTaskId(id, input, "createTask");
await store.assertTaskIdAvailable(id);
const title = input.title?.trim() || undefined;
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
? await store.resolveEnabledWorkflowSteps(
input.enabledWorkflowSteps,
await store.optionalGroupIdSet(input.workflowId),
)
: undefined;
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
let resolvedEntryColumn: string | undefined;
// U6/R3/KTD-4: an explicit create-time workflowId beats the project default,
// mirroring createTask(). `null` is an explicit opt-out, `string` materializes
// that workflow, `undefined` falls through to the default-workflow behavior.
// Explicit enabledWorkflowSteps still wins over workflowId for trusted callers.
/*
FNXC:WorkflowCreation 2026-07-05-14:30:
User-facing task creation can submit a selected workflowId and optional-group
toggles together. The visible workflow selection is operator intent and must
persist as task_workflow_selection; enabledWorkflowSteps only overrides that
workflow's default optional-group seed. Mirrors the SQLite-path fix
(FNXC:WorkflowCreation 2026-06-28-23:09) that these PostgreSQL-cutover copies
predated: previously a create submitting BOTH workflowId and
enabledWorkflowSteps silently skipped the selection row.
*/
const explicitWorkflowId = input.workflowId;
if (explicitWorkflowId !== undefined) {
if (explicitWorkflowId === null) {
// Explicit "No workflow": skip default materialization entirely.
resolvedWorkflowSteps = undefined;
} else {
// Compile + materialize up front so unknown/fragment ids throw BEFORE
// the task row is created (no orphaned steps, no half-created task).
const selected = await store.materializeExplicitWorkflowSteps(explicitWorkflowId);
const explicitStepIds = input.enabledWorkflowSteps !== undefined
? (resolvedWorkflowSteps ?? [])
: undefined;
resolvedWorkflowSteps = explicitStepIds ?? selected.stepIds;
resolvedEntryColumn = selected.entryColumnId;
pendingWorkflowSelection = {
workflowId: selected.workflowId,
stepIds: explicitStepIds ?? selected.stepIds,
};
}
} else if (input.enabledWorkflowSteps === undefined && options.applyDefaultWorkflowSteps !== false) {
// Mirror createTask: a configured project default workflow takes
// precedence over legacy default-on steps on this creation path too.
try {
const inherited = await store.materializeDefaultWorkflowSteps();
if (inherited) {
resolvedWorkflowSteps = inherited.stepIds;
resolvedEntryColumn = inherited.entryColumnId;
pendingWorkflowSelection = inherited;
}
} catch (err) {
storeLog.warn("Failed to apply default workflow during reserved task creation; falling back to default-on steps", {
phase: "createTaskWithReservedId:default-workflow",
error: err instanceof Error ? err.message : String(err),
});
}
if (resolvedWorkflowSteps === undefined) {
try {
const allSteps = await store.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 (input.enabledWorkflowSteps !== undefined) {
// Mirror of the backend path: the caller owns the optional-step toggles, so no
// materialization branch ran and `resolvedEntryColumn` was left undefined. The INTAKE
// COLUMN is still the project default workflow's — resolve it without materializing steps.
resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store);
if (input.enabledWorkflowSteps.length === 0) {
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
// optional-step selection must hydrate back as [], not undefined.
resolvedWorkflowSteps = [];
}
}
let createdTask: Task;
try {
createdTask = await store._createTaskInternal(input, title, resolvedWorkflowSteps, id, {
createdAt: options.createdAt,
updatedAt: options.updatedAt,
promptOverride: options.prompt,
invokeTaskCreatedHook: options.invokeTaskCreatedHook,
resolvedEntryColumn,
});
} catch (err) {
// The task row was never created, so any default-workflow steps we
// materialized above would orphan with no task/selection pointing at them.
await store.cleanupOrphanedMaterializedSteps(pendingWorkflowSelection?.stepIds);
if (input.proposalClaimId && isTaskIdConflictError(err)) {
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
if (existing) return existing;
}
throw err;
}
// Record the inherited workflow selection now that the task row exists.
if (pendingWorkflowSelection) {
try {
await store.writeTaskWorkflowSelection(createdTask.id, pendingWorkflowSelection.workflowId, pendingWorkflowSelection.stepIds);
} catch (err) {
storeLog.warn("Failed to record inherited workflow selection", {
taskId: createdTask.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
return createdTask;
}
export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
const now = options?.createdAt ?? new Date().toISOString();
// FN-5077: null normalized titles are treated as "no title" and allow standard fallback/summarization behavior.
const normalizedTitle = normalizeTitleForTaskId(title, id);
/*
FNXC:MergedPlanningColumn 2026-07-29-14:30 (U11 post-merge audit):
A project that has never explicitly set a default workflow has no persisted default row, so
`materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and
the row below fell through to the hard-coded `|| "triage"`. That column no longer exists in the
default workflow, so the card landed in a lane its own workflow does not declare: triage
discovery resolves intake BY TRAIT and never admits it, hold-release ignores it, and only
`reconcileUndeclaredTaskColumns` eventually re-homes it.
This is the OUT-OF-THE-BOX shape — `builtin:coding` is the IMPLICIT default via
DEFAULT_WORKFLOW_ID and nothing writes a default-workflow row until an operator picks one — so
it affected every new task on a fresh project rather than an edge case.
Resolved side-effect-free and ONLY as a last resort before the literal, so every path that
already has an explicit column or a resolved entry column is untouched. The literal survives as
the final fallback for a store that cannot resolve any workflow at all.
*/
// `workflowId: null` is an explicit "No workflow" opt-out — there is no workflow whose intake
// column could be resolved, so that path keeps the legacy literal.
const fallbackIntakeColumn = (input.column || options?.resolvedEntryColumn || input.workflowId === null)
? undefined
: await resolveDefaultWorkflowIntakeColumn(store);
/* Intake column + whether that intake is MANUAL (autoTriage:false), resolved from the IR. */
const intakeFacts = input.workflowId === null
? { manual: false as boolean, intake: undefined as string | undefined, hold: undefined as string | undefined }
: await resolveWorkflowIntakeFacts(store, input.workflowId ?? undefined);
const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride);
const task: Task = {
id,
lineageId: input.lineageId ?? generateTaskLineageId(),
proposalClaimId: input.proposalClaimId,
title: normalizedTitle.title ?? undefined,
description: input.description,
priority: normalizeTaskPriority(input.priority),
tokenUsage: input.tokenUsage,
declaredSymbols,
sourceIssue: input.sourceIssue,
githubTracking: input.githubTracking,
gitlabTracking: input.gitlabTracking,
sourceType: input.source?.sourceType ?? "unknown",
sourceAgentId: input.source?.sourceAgentId,
sourceRunId: input.source?.sourceRunId,
sourceSessionId: input.source?.sourceSessionId,
sourceMessageId: input.source?.sourceMessageId,
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext),
branchContext: input.branchContext,
autoMerge: input.autoMerge,
autoMergeProvenance: input.autoMerge === undefined ? undefined : "user",
// FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its
// workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when
// no explicit column is given (main FN-7591 parity).
column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
noCommitsExpected: input.noCommitsExpected === true ? true : undefined,
enabledWorkflowSteps: resolvedWorkflowSteps,
modelPresetId: input.modelPresetId,
assignedAgentId: input.assignedAgentId,
assigneeUserId: input.assigneeUserId,
scopeOverride: input.scopeOverride === true ? true : undefined,
scopeOverrideReason: input.scopeOverrideReason,
nodeId: input.nodeId,
modelProvider: input.modelProvider,
modelId: input.modelId,
validatorModelProvider: input.validatorModelProvider,
validatorModelId: input.validatorModelId,
planningModelProvider: input.planningModelProvider,
planningModelId: input.planningModelId,
mergerModelProvider: input.mergerModelProvider,
mergerModelId: input.mergerModelId,
thinkingLevel: input.thinkingLevel,
validatorThinkingLevel: input.validatorThinkingLevel,
planningThinkingLevel: input.planningThinkingLevel,
mergerThinkingLevel: input.mergerThinkingLevel,
reviewLevel: input.reviewLevel,
executionMode: input.executionMode,
// FNXC:PlannerOversight 2026-07-14-18:11: only set when create input is explicit boolean.
sessionAdvisorEnabled: typeof input.sessionAdvisorEnabled === "boolean" ? input.sessionAdvisorEnabled : undefined,
baseBranch: input.baseBranch,
branch: input.branch,
missionId: input.missionId,
sliceId: input.sliceId,
steps: [],
currentStep: 0,
log: [{ timestamp: now, action: "Task created" }],
columnMovedAt: now,
createdAt: now,
updatedAt: options?.updatedAt ?? now,
};
if (normalizedTitle.changed) {
task.log.push({
timestamp: now,
action: "Title normalized: stripped legacy task-id reference",
});
const removed = extractTaskIdTokens(title ?? "").filter((token) => token !== id.toUpperCase());
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
}
await store.maybeResolveTombstonedTaskId(id, input, "createTask");
await store.assertTaskIdAvailable(id);
const dir = store.taskDir(id);
await store.atomicCreateTaskJson(dir, task, "createTask");
// Update cache if watcher is active
if (store.isWatching) store.taskCache.set(id, { ...task });
/*
FNXC:CodingIdeasWorkflow 2026-07-05-19:45:
A freshly created task needs the bootstrap stub only when it lands in a
column the triage service will plan from — the legacy "triage" intake or a
workflow's resolved manual intake (e.g. Coding (Ideas) → "ideas"). Direct
creates into other columns keep generateSpecifiedPrompt (main parity).
*/
/*
FNXC:CodingIdeasWorkflow 2026-07-25-14:20:
Mirror of the backend path above — quick-add "Start" submits workflow id + post-intake column in
one request, so the card is unplanned despite not landing in the intake column. See the fuller
rationale there; keeping both copies in step is the whole point (this pair has drifted before).
*/
/*
FNXC:MergedPlanningColumn 2026-07-30-10:25 (Phase B — task-creation.ts to zero column literals):
Was `resolvedEntryColumn !== "triage"` — naming the DEFAULT workflow's intake id to mean "this
workflow has a MANUAL intake". Post-U11 the default's intake IS `todo`, so that comparison
became vacuously true for the default workflow and the guard stopped distinguishing the two
shapes it exists to separate.
The real fact is the intake trait's `autoTriage: false`, and the real shape is "the card landed
PAST its workflow's manual intake" — which is what quick-add Start does by submitting the
workflow id and the post-intake column together. Both now come from the IR, so the pinned
contract for a plain direct create on a default (auto-triage) workflow is preserved by the
`manual` test rather than by an id coincidence.
*/
const isUnplannedStartCreate = intakeFacts.manual
&& intakeFacts.intake !== undefined
&& intakeFacts.hold !== undefined
&& task.column !== intakeFacts.intake
&& task.column === intakeFacts.hold;
/*
FNXC:MergedPlanningColumn 2026-07-29-17:15 (PR #2589 review — greptile):
`fallbackIntakeColumn` must appear here, not only in the `column:` assignment above. Otherwise
this path lands the card in the resolved intake column and then classifies it NOT-intake,
writing `generateSpecifiedPrompt` instead of the bootstrap seed — and triage admits a card for
planning only when its PROMPT.md reads as a seed, so the card would rest in Planning already
looking "planned" and never be planned.
HONEST SCOPE: I could not construct a failing test for this through a public API. The only
in-tree caller of `_createTaskInternal` is `createTaskWithReservedIdImpl`, which passes its own
`resolvedEntryColumn` through options, so the second disjunct already matches and this path's
own fallback never decides. The fix is for the DIVERGENCE, which is a latent bug: two copies of
one predicate that disagree, where the backend copy needed exactly this clause. A direct
`_createTaskInternal` call — which the signature invites — would hit it.
*/
/*
FNXC:MergedPlanningColumn 2026-07-30-10:25 (Phase B):
The leading `task.column === "triage"` was the last-resort clause for a card whose workflow
could not be resolved. `intakeFacts.intake` covers that properly — it falls back to
DEFAULT_WORKFLOW_ID rather than to a bare id — so the literal is gone and an explicit
`column: "triage"` create on a workflow that still declares `triage` is matched through the
resolved intake rather than a coincidence of naming.
*/
const isIntakeColumn = (intakeFacts.intake !== undefined && task.column === intakeFacts.intake)
|| (options?.resolvedEntryColumn !== undefined && task.column === options.resolvedEntryColumn)
|| (fallbackIntakeColumn !== undefined && task.column === fallbackIntakeColumn)
|| isUnplannedStartCreate;
const usedBootstrapPrompt = !options?.promptOverride && isIntakeColumn;
const prompt = options?.promptOverride
?? (isIntakeColumn
? buildBootstrapPrompt(id, task.title, task.description)
: store.generateSpecifiedPrompt(task));
/*
FNXC:FileScopeClassification 2026-07-21-18:05:
Bootstrap intake prompts are freeform descriptions (GitHub issue bodies, paste dumps).
Do not hard-fail create on incidental `## File Scope` tokens in that prose — triage
will write a real planned PROMPT.md later. Strict validation still applies to
promptOverride and generateSpecifiedPrompt paths.
*/
if (!usedBootstrapPrompt) {
const validation = validateFileScopeInPromptContent(prompt);
if (validation.invalid.length > 0) {
if (store.isWatching) store.taskCache.delete(id);
store.deleteTaskById(id);
const { rm } = await import("node:fs/promises");
if (existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
}
throw new InvalidFileScopeError(id, validation.invalid);
}
}
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
await store._maybeAutoArchiveSameAgentDuplicate(task, input);
store.emitTaskLifecycleEventSafely("task:created", [task]);
if (options?.invokeTaskCreatedHook !== false) {
await store.invokeTaskCreatedHook(task);
}
return task;
}
/*
FNXC:SameAgentDuplicateIntake 2026-07-19-16:24:
FN-8401 requires PostgreSQL backendMode to use the FN-7658 flag-in-place policy,
not its former delete-on-match cleanup. One resolver reads tombstones through
listTasks(includeDeleted, includeArchived), so FN-5233 sticky near-duplicate blocking
includes soft-deletes whose delete lifecycle puts them in `archived` on both
persistence backends without a synchronous SQLite dependency.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-14:20:
Column trait flags for the intake duplicate guard, resolved from the candidates' OWN workflows.
WHY THIS PATH MATTERS MORE THAN THE OTHER TWO. `findSameAgentDuplicates` gained
`columnFlagsByColumnId` so a FINISHED sibling cannot be reused as the canonical for new work, and no
caller passed it. On the agent-tools paths the cost is a bad suggestion. Here it is DESTRUCTIVE: a
match either auto-archives the newly created task or, on the tombstoned branch, soft-deletes it and
removes its directory. So on a renamed board a new task could be archived or deleted as a duplicate
of work that had already finished.
Resolution is scoped to the columns the candidate set actually occupies — a handful of distinct ids,
not one read per card — and shares one IR cache.
*/
async function resolveIntakeDuplicateColumnFlags(
store: TaskStore,
candidates: ReadonlyArray<{ id: string; column: string }>,
): Promise<ReadonlyMap<string, { complete?: boolean; archived?: boolean }>> {
const byColumn = new Map<string, { complete?: boolean; archived?: boolean }>();
const irCache = new Map<string, WorkflowIr>();
const seenColumns = new Set<string>();
for (const candidate of candidates) {
if (seenColumns.has(candidate.column)) continue;
seenColumns.add(candidate.column);
const lanes = await resolveTaskLifecycleColumns(store, candidate.id, irCache).catch(() => undefined);
if (!lanes) continue;
if (lanes.complete !== undefined) byColumn.set(lanes.complete, { ...byColumn.get(lanes.complete), complete: true });
if (lanes.archived !== undefined) byColumn.set(lanes.archived, { ...byColumn.get(lanes.archived), archived: true });
}
return byColumn;
}
export async function resolveSameAgentDuplicateIntake(store: TaskStore, task: Task, input: TaskCreateInput): Promise<void> {
const sourceAgentId = task.sourceAgentId ?? null;
const sourceParentTaskId = task.sourceParentTaskId ?? null;
if (!sourceAgentId && !sourceParentTaskId) return;
try {
const nowMs = Date.now();
const settings = await store.getSettings();
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
const allCandidates = await store.listTasks({ slim: true, includeArchived: true, includeDeleted: true });
const matches = findSameAgentDuplicates(
{ title: input.title ?? task.title, description: input.description, sourceParentTaskId },
allCandidates.flatMap<SameAgentDuplicateCandidate>((candidate) => {
if (candidate.id === task.id) return [];
const createdAt = Date.parse(candidate.createdAt);
if (Number.isNaN(createdAt)) return [];
if (candidate.deletedAt) {
const deletedAtMs = Date.parse(candidate.deletedAt);
if (sourceAgentId == null
|| candidate.sourceAgentId !== sourceAgentId
|| Number.isNaN(deletedAtMs)
|| stickyWindowDays <= 0
|| deletedAtMs < nowMs - stickyWindowDays * 24 * 60 * 60 * 1000) return [];
return [{
id: candidate.id, title: candidate.title ?? "", description: candidate.description,
column: candidate.column, createdAt, sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null, tombstoned: true,
deletedAt: candidate.deletedAt, allowResurrection: candidate.allowResurrection === true,
}];
}
const agentMatch = sourceAgentId != null && candidate.sourceAgentId === sourceAgentId;
const parentMatch = sourceParentTaskId != null && candidate.sourceParentTaskId === sourceParentTaskId;
if (!agentMatch && !parentMatch) return [];
return [{
id: candidate.id, title: candidate.title ?? "", description: candidate.description,
column: candidate.column, createdAt, sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null, tombstoned: false,
}];
}),
{ nowMs, sourceAgentId, columnFlagsByColumnId: await resolveIntakeDuplicateColumnFlags(store, allCandidates) },
);
if (matches.length === 0) return;
const tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
if (tombstonedMatch?.deletedAt) {
const metadata = {
matchedTaskId: tombstonedMatch.id, score: tombstonedMatch.score,
tombstoneDeletedAt: tombstonedMatch.deletedAt, stickyWindowDays,
};
await recordRunAuditEventAsync(store.asyncLayer!, {
taskId: task.id, agentId: "system", runId: `store:intake:resurrection-blocked:${task.id}`,
domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata,
});
await softDeleteTaskRowAsync(store.asyncLayer!, task.id, new Date().toISOString());
if (store.isWatching) store.taskCache.delete(task.id);
const taskDir = store.taskDir(task.id);
if (existsSync(taskDir)) await rm(taskDir, { recursive: true, force: true });
throw new TombstonedTaskResurrectionError(tombstonedMatch.id, tombstonedMatch.deletedAt, false);
}
const siblingTaskIds = matches.filter((match) => !match.tombstoned).map((match) => match.id);
if (siblingTaskIds.length === 0) return;
const scores = Object.fromEntries(matches.filter((match) => !match.tombstoned).map((match) => [match.id, match.score]));
if (settings.autoArchiveDuplicateTasksEnabled === true) {
await archiveAsSameAgentDuplicate(store, task.id, siblingTaskIds, scores);
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-14:20:
Mirror the archive into the in-memory row using the board's OWN archived lane. Writing the
literal here made the returned object disagree with what the archive actually did on a renamed
board — the caller then saw a task claiming a column its workflow does not declare, the same
shape as the `"triage"` write fixed earlier in this program.
*/
const archivedLane = (await resolveTaskLifecycleColumns(store, task.id).catch(() => undefined))?.archived;
/* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-31-14:20. */
task.column = archivedLane ?? "archived";
} else {
const appliedPatch = await flagSameAgentDuplicate(store, task.id, siblingTaskIds, scores);
if (appliedPatch) task.sourceMetadata = { ...(task.sourceMetadata ?? {}), ...appliedPatch };
}
} catch (error) {
if (error instanceof TombstonedTaskResurrectionError) throw error;
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
}
}
export async function _maybeAutoArchiveSameAgentDuplicateImpl(store: TaskStore, task: Task, input: TaskCreateInput): Promise<void> {
return resolveSameAgentDuplicateIntake(store, task, input);
}