FN-8545: enforce mission lineage task admission

Protect feature bootstrap task creation with mission-lineage admission rules.

- Restrict supervised agents to linked autonomous mission features
- Atomically claim defined feature tasks and preserve duplicate ownership
- Lock defined feature claims to prevent concurrent bootstrap inserts
- Document admission behavior and add PostgreSQL and delegation coverage

Files changed:
 .changeset/fn-8545-mission-admission.md            |   7 +
 docs/agents.md                                     |   6 +
 docs/missions.md                                   |  12 +-
 .../__tests__/postgres/mission-store.pg.test.ts    | 119 ++++++++++++
 packages/core/src/async-mission-store.ts           | 203 +++++++++++++++++++--
 packages/core/src/duplicate-guard.ts               |  14 +-
 packages/core/src/mission-store.ts                 |  15 ++
 packages/core/src/task-store/task-creation.ts      | 152 ++++++++++-----
 .../src/__tests__/agent-tools-delegation.test.ts   | 152 ++++++++++++++-
 packages/engine/src/agent-tools.ts                 | 151 ++++++++++++++-
 10 files changed, 753 insertions(+), 78 deletions(-)

Fusion-Task-Id: FN-8545

Fusion-Task-Lineage: c6e1b46d-f434-4b07-93bc-27e1f6b491b1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 14:21:29 -07:00
parent ffbda3fb52
commit 3d0ce2ed3a
10 changed files with 753 additions and 78 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix supervised task creation and defined-feature mission bootstrap admission.
category: fix
dev: No-task heartbeat creates still require approved lineage; first defined-feature tasks link and triage safely.

View File

@@ -83,6 +83,12 @@ printf "deploy report" | fn chat agent-abc123 --once --non-interactive
> Replies require a running engine for the same project (for example `fn` dashboard or `fn serve`).
## Mission lineage and task creation
`fn_task_create` and `fn_delegate_task` use two distinct controls. In an autonomous no-task heartbeat, the caller must provide approved `mission_lineage` (mission, slice, and feature); a rejection states that approved mission lineage is required, and no permission grant overrides it. In interactive/user-supervised and task-scoped sessions, lineage is optional and `task_agent_mutation` category rules and exact-tool overrides decide whether creation is allowed, requires approval, or is blocked.
A valid active lineage can bootstrap the first task for a hand-authored `defined` feature. The feature is linked to that exact task and promoted to `triaged`; later autonomous scheduler work still requires a `triaged` or `in-progress` feature.
## Agent configuration updates from agents
The `fn_agent_update` extension tool lets chat/extension callers update existing non-ephemeral agents in place instead of deleting and recreating them. It accepts `agent_id` plus any editable subset of:

View File

@@ -23,6 +23,14 @@ Mission: Improve Reliability
Task: FN-214
```
## Agent task-creation admission
Mission lineage is an admission requirement only for **autonomous no-task heartbeat** creates and delegations. Those idle patrol calls must supply a valid active Mission → Milestone → Slice → Feature chain; an allow rule for `task_agent_mutation` cannot bypass this requirement. Missing or invalid lineage is rejected before a task is persisted with an explicit mission-lineage remedy.
Interactive/user-supervised, task-scoped heartbeat, executor, triage, and workflow-step calls may create or delegate freeform tasks without lineage. They remain governed by the normal `task_agent_mutation` permission policy, including category and exact-tool allow, approval, and block rules.
A valid active lineage may name a hand-authored `defined` feature only for its first task. Fusion atomically claims the feature, links that exact task, and promotes the feature to `triaged`; an already-linked feature rejects rather than overwriting its canonical task. This bootstrap exception does not make `defined` executable: later scheduler and symbol-lock admission still uses the stricter contract below.
## Canonical lineage approval for autonomous symbol locks
Before autonomous scheduler work may acquire a symbol lock, it resolves the task's Mission → Milestone → Slice → Feature lineage and evaluates the single `@fusion/core` contract: `evaluateMissionLineageApproval`. Resolution and lock acquisition remain scheduler responsibilities; downstream schedulers must not redefine the approval rule.
@@ -32,7 +40,7 @@ Approval requires every one of these statuses:
- Mission: `active`
- Milestone: `active`
- Slice: `active`
- Feature: `triaged` or `in-progress`
- Feature: `triaged` or `in-progress` (never `defined`; defined is only allowed at the first-task bootstrap boundary)
When the scheduler passes `planApprovalRequired: true`, the linked task must also have an `approvedPlanFingerprint` that is a non-empty string after trimming whitespace. The predicate does not recompute the fingerprint; `plan-approval.ts` owns its generation and validation. When plan approval is not required, the fingerprint is ignored.
@@ -722,4 +730,4 @@ A completed cited research finding may become a normal Mission Feature. Its feat
### Autonomous mission admission
Heartbeat agents may create or delegate implementation work only with an approved Feature → Slice → Milestone → Mission lineage. The created task stores that lineage as task metadata; it does not replace the canonical feature `taskId` link. Missing or invalid lineage is rejected before a task is persisted. Roadmap reconciliation marks done tasks done, returns cancelled/requeued tasks to triaged, keeps failed work non-complete, and treats archives as non-promoting no-ops.
Autonomous no-task heartbeat agents may create or delegate implementation work only with an approved Feature → Slice → Milestone → Mission lineage. Interactive and task-scoped calls remain governed by `task_agent_mutation` policy as described in [Agent task-creation admission](#agent-task-creation-admission). The created task stores that lineage as task metadata; it does not replace the canonical feature `taskId` link except at the documented `defined`-feature first-task bootstrap. Missing or invalid autonomous lineage is rejected before a task is persisted. Roadmap reconciliation marks done tasks done, returns cancelled/requeued tasks to triaged, keeps failed work non-complete, and treats archives as non-promoting no-ops.

View File

@@ -15,6 +15,9 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { readFile } from "node:fs/promises";
import type { DbTransaction } from "../../postgres/data-layer.js";
import type { TaskCreateInput } from "../../types/task-core.js";
import {
pgDescribe,
@@ -244,6 +247,122 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(unlinked.status).toBe("defined");
});
it("does not overwrite an existing task directory on a creation collision", async () => {
const taskStore = h.store();
const existing = await taskStore.createTask({ description: "the existing task must keep its prompt" });
const existingDir = taskStore.taskDir(existing.id);
const originalPrompt = await readFile(`${existingDir}/PROMPT.md`, "utf8");
const allocator = {
reserveDistributedTaskId: vi.fn().mockResolvedValue({ taskId: existing.id, reservationId: "duplicate-id-reservation" }),
commitDistributedTaskIdReservation: vi.fn().mockResolvedValue(undefined),
abortDistributedTaskIdReservation: vi.fn().mockResolvedValue(undefined),
};
const allocatorSpy = vi.spyOn(taskStore, "getDistributedTaskIdAllocator").mockReturnValue(allocator as ReturnType<typeof taskStore.getDistributedTaskIdAllocator>);
try {
await expect(taskStore.createTask({ description: "a competing task must not overwrite files" }))
.rejects.toThrow(`Task ID already exists: ${existing.id}`);
/* FNXC:MissionAdmission 2026-07-23-19:00: a task-row collision leaves the winner's final artifacts untouched because the loser wrote only its staging directory. */
await expect(readFile(`${existingDir}/PROMPT.md`, "utf8")).resolves.toBe(originalPrompt);
} finally {
allocatorSpy.mockRestore();
}
});
it("does not claim a defined feature when task-file materialization fails", async () => {
const m = missions();
const mission = await m.createMission({ title: "Bootstrap file failure" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "Target feature" });
const taskStore = h.store();
const claim = vi.fn(async (tx: DbTransaction, taskId: string) =>
m.claimDefinedFeatureTaskInTransaction(tx, {
featureId: feature.id,
taskId,
missionId: mission.id,
sliceId: slice.id,
}),
);
const writeTaskJson = vi.spyOn(taskStore, "writeTaskJsonFile").mockRejectedValueOnce(new Error("injected task-file failure"));
try {
await expect(taskStore.createTask({
description: "must not become a partial feature bootstrap",
missionId: mission.id,
sliceId: slice.id,
afterTaskInsert: (tx: DbTransaction, task: { id: string }) => claim(tx, task.id),
} as TaskCreateInput & { afterTaskInsert: (tx: DbTransaction, task: { id: string }) => Promise<void> })).rejects.toThrow("injected task-file failure");
} finally {
writeTaskJson.mockRestore();
}
/* FNXC:MissionAdmission 2026-07-23-17:10: filesystem failure precedes the insert-and-claim transaction, so no feature promotion can survive a failed task create. */
expect(claim).not.toHaveBeenCalled();
expect(await m.getFeature(feature.id)).toMatchObject({ status: "defined", taskId: undefined });
expect((await taskStore.listTasks()).some((task) => task.description === "must not become a partial feature bootstrap")).toBe(false);
});
it("rejects an unlinked duplicate canonical even when its mission and slice match", async () => {
const m = missions();
const mission = await m.createMission({ title: "Bootstrap duplicate guard" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "Target feature" });
const task = await h.store().createTask({
description: "existing work for another feature",
missionId: mission.id,
sliceId: slice.id,
});
/*
FNXC:MissionAdmission 2026-07-23-17:20:
A duplicate canonical does not inherit a feature merely because it shares a
slice. Only the insert transaction may claim a defined feature for a new
task; retry reconciliation requires an existing bidirectional link.
*/
await expect(m.claimDefinedFeatureTask({
featureId: feature.id,
taskId: task.id,
missionId: mission.id,
sliceId: slice.id,
})).rejects.toThrow("is not linked to this feature");
expect(await m.getFeature(feature.id)).toMatchObject({ status: "defined", taskId: undefined });
});
it("preserves a late bootstrap duplicate already linked to another feature", async () => {
const m = missions();
const mission = await m.createMission({ title: "Bootstrap sibling ownership" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const firstFeature = await m.addFeature(slice.id, { title: "First feature" });
const siblingFeature = await m.addFeature(slice.id, { title: "Sibling feature" });
const taskStore = h.store();
const claimedTask = await taskStore.createTask({
description: "same fingerprint work",
missionId: mission.id,
sliceId: slice.id,
});
await m.linkFeatureToTask(firstFeature.id, claimedTask.id);
const siblingTask = await taskStore.createTask({
description: "same fingerprint work",
missionId: mission.id,
sliceId: slice.id,
});
await m.linkFeatureToTask(siblingFeature.id, siblingTask.id);
await m.archiveDefinedFeatureBootstrapDuplicate({
featureId: firstFeature.id,
taskId: claimedTask.id,
duplicateTaskId: siblingTask.id,
});
/* FNXC:MissionAdmission 2026-07-23-21:10: a late same-fingerprint task claimed by another feature is not a duplicate eligible for archival. */
expect(await taskStore.getTask(siblingTask.id)).toMatchObject({ id: siblingTask.id, column: "triage" });
expect(await m.getFeature(siblingFeature.id)).toMatchObject({ taskId: siblingTask.id, status: "triaged" });
expect(await m.getFeature(firstFeature.id)).toMatchObject({ taskId: claimedTask.id, status: "triaged" });
});
/*
FNXC:MissionReconciliation 2026-07-20-08:34:
Regression coverage exercises every terminal-evidence representation through the real PostgreSQL store. Reconciliation must never route through ordinary triage linking, mutate loop attempts or mission controls, or partially commit when the transaction fails.

View File

@@ -1022,27 +1022,198 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
return outcome.feature;
}
async linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
const feature = await getFeature(this.db, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
const liveTask = await getLiveTaskById(this.db, taskId);
if (!liveTask) {
throw new Error(
`Cannot link feature ${featureId} to task ${taskId}: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
);
/**
* Atomically claim a hand-authored defined feature for a task already inserted
* in the caller's transaction. This is intentionally narrower than
* linkFeatureToTask(): existing manual links may establish task lineage,
* whereas a duplicate bootstrap canonical must already prove this lineage.
*/
async claimDefinedFeatureTaskInTransaction(
tx: import("./postgres/data-layer.js").DbTransaction,
input: { featureId: string; taskId: string; missionId: string; sliceId: string; requireExistingFeatureLink?: boolean },
): Promise<MissionFeature> {
/*
FNXC:MissionAdmission 2026-07-23-15:30:
Defined is creation-admissible only at the first-task claim boundary. Check
the project-scoped task's existing lineage before updating either record so
a deterministic duplicate from another mission can never be repurposed.
*/
/*
FNXC:MissionAdmission 2026-08-10-00:00:
Concurrent bootstrap requests must serialize on the defined Feature before
either inserts its task. READ COMMITTED alone permits both readers to claim
it; this row lock makes the second request re-read the committed taskId and
reject, preserving one exclusive first-task claim.
*/
const lockedFeatures = await tx
.select({ id: schema.project.missionFeatures.id })
.from(schema.project.missionFeatures)
.where(eq(schema.project.missionFeatures.id, input.featureId))
.for("update");
if (lockedFeatures.length === 0) throw new Error(`Feature ${input.featureId} not found`);
const feature = await getFeature(tx, input.featureId);
if (!feature) throw new Error(`Feature ${input.featureId} not found`);
if (feature.sliceId !== input.sliceId) throw new Error(`Feature ${input.featureId} does not belong to slice ${input.sliceId}`);
if (feature.taskId && feature.taskId !== input.taskId) {
throw new Error(`Feature ${input.featureId} is already linked to task ${feature.taskId}`);
}
const linkage = await this.resolveTaskLinkage(feature.sliceId);
if (feature.status !== "defined" && feature.taskId !== input.taskId) {
throw new Error(`Feature ${input.featureId} is not available for first-task bootstrap`);
}
/*
FNXC:MissionAdmission 2026-07-23-17:20:
A duplicate canonical was not inserted in this transaction. It may be
reused only after this feature already owns it; allowing an arbitrary
unlinked task from the same slice would silently assign another feature's
work to this bootstrap request.
*/
if (input.requireExistingFeatureLink === true && feature.taskId !== input.taskId) {
throw new Error(`Cannot bootstrap feature ${input.featureId}: pre-existing task ${input.taskId} is not linked to this feature`);
}
const projectId = this.layer.projectId;
if (!projectId) throw new Error("Defined-feature bootstrap requires a project-scoped data layer");
const taskRows = await tx
.select({ id: schema.project.tasks.id, missionId: schema.project.tasks.missionId, sliceId: schema.project.tasks.sliceId, column: schema.project.tasks.column })
.from(schema.project.tasks)
.where(and(
eq(schema.project.tasks.projectId, projectId),
eq(schema.project.tasks.id, input.taskId),
sql`${schema.project.tasks.deletedAt} is null`,
));
const task = taskRows[0];
if (!task || task.column === "archived") {
throw new Error(`Cannot bootstrap feature ${input.featureId}: task ${input.taskId} is not active in this project`);
}
if (task.missionId !== input.missionId || task.sliceId !== input.sliceId) {
throw new Error(`Cannot bootstrap feature ${input.featureId}: task ${input.taskId} has unrelated mission lineage`);
}
const conflict = await getConflictingFeatureByTaskId(tx, input.taskId, input.featureId);
if (conflict) throw new Error(`Task ${input.taskId} is already linked to feature ${conflict.id}`);
const now = new Date().toISOString();
const shouldTransitionLoop = !feature.loopState || feature.loopState === "idle";
const loopStateUpdates: Partial<MissionFeature> = shouldTransitionLoop
? { loopState: "implementing", implementationAttemptCount: 1 }
: {};
const updated = await this.updateFeature(featureId, { taskId, status: "triaged", ...loopStateUpdates });
await setTaskMissionLinkage(this.db, taskId, linkage.missionId, linkage.sliceId);
await this.recomputeSliceStatus(updated.sliceId);
this.emit("feature:linked", { feature: updated, taskId });
const updated: MissionFeature = {
...feature,
taskId: input.taskId,
status: "triaged",
...(shouldTransitionLoop ? { loopState: "implementing", implementationAttemptCount: 1 } : {}),
updatedAt: now,
};
await updateFeature(tx, updated);
// The inserted task already carries this verified linkage; retain this write
// for retry parity when the same canonical is claimed again.
await setTaskMissionLinkage(tx, input.taskId, input.missionId, input.sliceId);
return updated;
}
async claimDefinedFeatureTask(input: { featureId: string; taskId: string; missionId: string; sliceId: string }): Promise<MissionFeature> {
const feature = await this.layer.transactionImmediate((tx) => this.claimDefinedFeatureTaskInTransaction(tx, { ...input, requireExistingFeatureLink: true }));
this.emit("feature:updated", feature);
this.emit("feature:linked", { feature, taskId: input.taskId });
await this.recomputeSliceStatus(feature.sliceId);
return feature;
}
/**
* Keep the task that atomically claimed a defined Feature as the sole live
* deterministic-duplicate canonical. This compensates for a duplicate that
* became visible only after the create preflight, without ever allowing the
* generic intake path to archive feature.taskId.
*/
async archiveDefinedFeatureBootstrapDuplicate(input: { featureId: string; taskId: string; duplicateTaskId: string }): Promise<void> {
/*
FNXC:MissionAdmission 2026-07-23-21:10:
Project-agnostic legacy stores remain scoped to their reserved RLS
partition, so reconciliation never falls back to an unscoped task ID.
*/
const projectId = this.layer.projectId || "__legacy_unscoped__";
await this.layer.transactionImmediate(async (tx) => {
/*
FNXC:MissionAdmission 2026-07-23-20:00:
A late deterministic duplicate must not reverse the first-task claim and
archive feature.taskId. Verify that the feature still owns the claimed,
project-scoped live task, then archive only the competing live task in
this transaction. `defined` remains scheduler-ineligible throughout.
*/
const feature = await getFeature(tx, input.featureId);
if (!feature || feature.taskId !== input.taskId || feature.status !== "triaged") {
throw new Error(`Cannot reconcile defined-feature bootstrap duplicate for ${input.featureId}`);
}
const claimed = await tx.select({ id: schema.project.tasks.id })
.from(schema.project.tasks)
.where(and(
eq(schema.project.tasks.projectId, projectId),
eq(schema.project.tasks.id, input.taskId),
sql`${schema.project.tasks.deletedAt} is null`,
sql`${schema.project.tasks.column} <> 'archived'`,
));
if (!claimed[0]) throw new Error(`Cannot reconcile defined-feature bootstrap duplicate: claimed task ${input.taskId} is not live`);
/*
FNXC:MissionAdmission 2026-07-23-21:10:
Fingerprint equality does not make work interchangeable across Features.
A late sibling already claimed by another Feature remains live; archiving
it here would corrupt that Feature's canonical task. Keep both tasks and
let each feature retain its own transactional bootstrap claim.
*/
const duplicateFeature = await getConflictingFeatureByTaskId(tx, input.duplicateTaskId, input.featureId);
if (duplicateFeature) return;
await tx.update(schema.project.tasks)
.set({ column: "archived", updatedAt: new Date().toISOString() })
.where(and(
eq(schema.project.tasks.projectId, projectId),
eq(schema.project.tasks.id, input.duplicateTaskId),
sql`${schema.project.tasks.deletedAt} is null`,
sql`${schema.project.tasks.column} <> 'archived'`,
));
});
}
async linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
/*
FNXC:MissionAdmission 2026-07-23-12:00:
First-task bootstrap must claim the feature, promote it, and backlink the
exact project-scoped task as one transaction. Never overwrite a feature's
existing taskId: retries may reuse only that same canonical task.
*/
const outcome = await this.layer.transactionImmediate(async (tx) => {
const feature = await getFeature(tx, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
if (feature.taskId && feature.taskId !== taskId) {
throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`);
}
const liveTask = await getLiveTaskById(tx, taskId);
if (!liveTask) {
throw new Error(
`Cannot link feature ${featureId} to task ${taskId}: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
);
}
const conflictingFeature = await getConflictingFeatureByTaskId(tx, taskId, featureId);
if (conflictingFeature) {
throw new Error(`Task ${taskId} is already linked to feature ${conflictingFeature.id}`);
}
const slice = await getSlice(tx, feature.sliceId);
const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined;
if (!slice || !milestone) throw new Error(`Feature ${featureId} has incomplete mission hierarchy`);
const shouldTransitionLoop = !feature.loopState || feature.loopState === "idle";
const now = new Date().toISOString();
const updated: MissionFeature = {
...feature,
taskId,
status: "triaged",
...(shouldTransitionLoop ? { loopState: "implementing", implementationAttemptCount: 1 } : {}),
updatedAt: now,
};
await updateFeature(tx, updated);
await setTaskMissionLinkage(tx, taskId, milestone.missionId, slice.id);
return updated;
});
this.emit("feature:updated", outcome);
this.emit("feature:linked", { feature: outcome, taskId });
await this.recomputeSliceStatus(outcome.sliceId);
return outcome;
}
async unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
const feature = await getFeature(this.db, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);

View File

@@ -139,8 +139,10 @@ export async function reconcileDeterministicDuplicate(
windowMs?: number;
sourceParentTaskId?: string | null;
logger?: { warn(msg: string, data?: Record<string, unknown>): void };
/** Handle a duplicate without archiving `createdTask` (e.g. claimed feature bootstrap). */
onDuplicate?: (canonical: Task) => Promise<"keep-created" | "archive-created">;
},
): Promise<{ outcome: "kept" | "archived"; canonical: Task }> {
): Promise<{ outcome: "kept" | "archived" | "kept-duplicate"; canonical: Task }> {
if (!args.fingerprint) {
return { outcome: "kept", canonical: args.createdTask };
}
@@ -160,6 +162,16 @@ export async function reconcileDeterministicDuplicate(
return { outcome: "kept", canonical: args.createdTask };
}
/*
FNXC:MissionAdmission 2026-07-23-20:00:
A defined-feature bootstrap has already transactionally made its inserted
task feature.taskId. Let that caller reconcile the older sibling without
routing the generic duplicate path through an archive of the claimed row.
*/
if (await args.onDuplicate?.(olderSibling) === "keep-created") {
return { outcome: "kept-duplicate", canonical: args.createdTask };
}
await store.updateTask(args.createdTask.id, {
sourceMetadataPatch: {
contentFingerprint: args.fingerprint,

View File

@@ -2461,6 +2461,21 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
);
}
if (feature.taskId && feature.taskId !== taskId) {
throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`);
}
const conflictingFeature = this.db
.prepare(`SELECT id FROM mission_features WHERE taskId = ? AND id != ? LIMIT 1`)
.get(taskId, featureId) as { id: string } | undefined;
if (conflictingFeature) {
throw new Error(`Task ${taskId} is already linked to feature ${conflictingFeature.id}`);
}
/*
FNXC:MissionAdmission 2026-07-23-12:00:
Keep sync test-contract parity with PostgreSQL: a defined feature's first
task claim is exclusive and must never overwrite another feature backlink.
*/
const linkage = this.resolveTaskLinkage(feature.sliceId);
// When first linking (loopState is idle or falsy), transition to implementing

View File

@@ -8,9 +8,10 @@
*/
import {TaskStore, storeLog} from "../store.js";
import {InvalidFileScopeError, SelfDefeatingDependencyError, detectSelfDefeatingDependency, TombstonedTaskResurrectionError} from "./errors.js";
import {mkdir, rm, writeFile} from "node:fs/promises";
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";
@@ -29,6 +30,17 @@ import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-contex
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";
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 {
/*
@@ -371,49 +383,44 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
}
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: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);
/*
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 {
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);
});
} catch (error) {
/*
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.
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.
*/
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;
}
await mkdir(stagingDir, { recursive: true });
ownsStagingDirectory = true;
// FNXC:ReservationAtomicity 2026-07-12-00:00:
// Wrap post-insert filesystem/prompt work so any failure rolls back the
// inserted row. Without this, a writeTaskJsonFile or prompt-validation throw
// leaves a live row paired with an aborted reservation (FN-7074 invariant).
try {
// Write task.json for backward compatibility and debugging.
if (store.isWatching) store.taskCache.set(id, { ...task });
await store.writeTaskJsonFile(dir, task);
await store.writeTaskJsonFile(stagingDir, task);
// Write PROMPT.md (same logic as SQLite path).
/*
@@ -443,20 +450,73 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
throw new InvalidFileScopeError(id, validation.invalid);
}
}
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
await writeFile(join(stagingDir, "PROMPT.md"), prompt);
} catch (error) {
// Rollback: soft-delete the inserted row and remove the directory.
await softDeleteTaskRowAsync(layer, id, new Date().toISOString());
if (store.isWatching) store.taskCache.delete(id);
if (existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
await cleanupPreparedTaskFiles();
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
throw new Error(`Task ID already exists: ${task.id}`);
}
throw error;
}
// Auto-archive dedup (best-effort, same as SQLite path but using async reads).
await store._maybeAutoArchiveSameAgentDuplicateBackend(task, input);
// 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) {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Agent, AgentStore, TaskStore, Task } from "@fusion/core";
import type { Agent, AgentStore, TaskStore, Task, TaskCreateInput } from "@fusion/core";
import { createAgentTask, createListAgentsTool, createDelegateTaskTool, createTaskCreateTool } from "../agent-tools.js";
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
@@ -553,6 +553,142 @@ describe("createDelegateTaskTool", () => {
expect(createInput.sliceId).toBeUndefined();
});
it("bootstraps a defined feature by linking and promoting its first created task", async () => {
const missionStore = {
getFeature: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "defined" }),
getSlice: vi.fn().mockResolvedValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
getMilestone: vi.fn().mockResolvedValue({ id: "MS-001", missionId: "M-001", status: "active" }),
getMission: vi.fn().mockResolvedValue({ id: "M-001", status: "active" }),
claimDefinedFeatureTaskInTransaction: vi.fn().mockResolvedValue({ id: "F-001", taskId: "FN-001", status: "triaged" }),
claimDefinedFeatureTask: vi.fn().mockResolvedValue({ id: "F-001", taskId: "FN-001", status: "triaged" }),
archiveDefinedFeatureBootstrapDuplicate: vi.fn().mockResolvedValue(undefined),
};
const store = createMockTaskStore({
getMissionStore: vi.fn().mockReturnValue(missionStore),
createTask: vi.fn().mockImplementation(async (input) => {
const task = { id: "FN-001", dependencies: [], column: "triage", steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" } as Task;
await (input as { afterTaskInsert?: (tx: object, created: Task) => Promise<void> }).afterTaskInsert?.({}, task);
return task;
}),
});
const result = await createTaskCreateTool(store).execute(
"call-1", { description: "Bootstrap the hand-authored feature", mission_lineage: APPROVED_LINEAGE },
undefined as any, undefined as any, undefined as any,
);
expect(result).not.toMatchObject({ isError: true });
expect(missionStore.claimDefinedFeatureTaskInTransaction).toHaveBeenCalledWith({}, { featureId: "F-001", taskId: "FN-001", missionId: "M-001", sliceId: "SL-001" });
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ missionId: "M-001", sliceId: "SL-001" }), expect.anything());
});
it("keeps a claimed defined-feature task canonical when a late duplicate appears", async () => {
const missionStore = {
getFeature: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "defined" }),
getSlice: vi.fn().mockResolvedValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
getMilestone: vi.fn().mockResolvedValue({ id: "MS-001", missionId: "M-001", status: "active" }),
getMission: vi.fn().mockResolvedValue({ id: "M-001", status: "active" }),
claimDefinedFeatureTaskInTransaction: vi.fn().mockResolvedValue({ id: "F-001", taskId: "FN-new", status: "triaged" }),
claimDefinedFeatureTask: vi.fn(),
archiveDefinedFeatureBootstrapDuplicate: vi.fn().mockResolvedValue(undefined),
};
const created = { id: "FN-new", description: "Bootstrap feature", dependencies: [], column: "triage" as const, steps: [], currentStep: 0, log: [], createdAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" } as Task;
const older = { ...created, id: "FN-old", createdAt: "2026-01-01T00:00:00.000Z" };
const store = createMockTaskStore({
getMissionStore: vi.fn().mockReturnValue(missionStore),
createTask: vi.fn().mockImplementation(async (input) => {
await (input as { afterTaskInsert?: (tx: object, created: Task) => Promise<void> }).afterTaskInsert?.({}, created);
return created;
}),
findRecentTasksByContentFingerprint: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([older, created]),
});
const result = await createTaskCreateTool(store).execute(
"call-1", { description: "Bootstrap feature", mission_lineage: APPROVED_LINEAGE },
undefined as any, undefined as any, undefined as any,
);
expect(result).not.toMatchObject({ isError: true });
expect((result.details as { taskId: string }).taskId).toBe("FN-new");
expect(missionStore.claimDefinedFeatureTaskInTransaction).toHaveBeenCalledOnce();
/* FNXC:MissionAdmission 2026-07-23-19:00: a task that atomically claimed feature.taskId must never be archived by post-create duplicate reconciliation. */
expect(store.findRecentTasksByContentFingerprint).toHaveBeenCalledTimes(2);
expect(missionStore.archiveDefinedFeatureBootstrapDuplicate).toHaveBeenCalledWith({
featureId: "F-001", taskId: "FN-new", duplicateTaskId: "FN-old",
});
expect(store.moveTask).not.toHaveBeenCalledWith("FN-new", "archived");
});
it("rolls back a newly-created task when defined-feature bootstrap cannot link", async () => {
const missionStore = {
getFeature: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "defined" }),
getSlice: vi.fn().mockResolvedValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
getMilestone: vi.fn().mockResolvedValue({ id: "MS-001", missionId: "M-001", status: "active" }),
getMission: vi.fn().mockResolvedValue({ id: "M-001", status: "active" }),
claimDefinedFeatureTaskInTransaction: vi.fn().mockRejectedValue(new Error("Feature F-001 is already linked to task FN-OTHER")),
claimDefinedFeatureTask: vi.fn(),
archiveDefinedFeatureBootstrapDuplicate: vi.fn().mockResolvedValue(undefined),
};
const store = createMockTaskStore({
getMissionStore: vi.fn().mockReturnValue(missionStore),
createTask: vi.fn().mockImplementation(async (input) => {
await (input as { afterTaskInsert?: (tx: object, created: Task) => Promise<void> }).afterTaskInsert?.({}, { id: "FN-001" } as Task);
throw new Error("bootstrap hook unexpectedly succeeded");
}),
});
const result = createTaskCreateTool(store).execute(
"call-1", { description: "Bootstrap conflicting feature", mission_lineage: APPROVED_LINEAGE },
undefined as any, undefined as any, undefined as any,
);
await expect(result).rejects.toThrow("Feature F-001 is already linked to task FN-OTHER");
expect(missionStore.claimDefinedFeatureTaskInTransaction).toHaveBeenCalledOnce();
});
it("rejects a pre-existing same-agent bootstrap duplicate before claiming or creating", async () => {
const canonical = {
id: "FN-existing", title: "Bootstrap feature", description: "Bootstrap the hand-authored feature",
sourceAgentId: "agent-001", dependencies: [], column: "triage" as const, steps: [], currentStep: 0,
log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} as Task;
const store = createMockTaskStore({ listTasks: vi.fn().mockResolvedValue([canonical]) });
const validate = vi.fn().mockRejectedValue(new Error("pre-existing task is not linked to this feature"));
await expect(createAgentTask(store, {
title: "Bootstrap feature",
description: "Bootstrap the hand-authored feature",
source: { sourceType: "api", sourceAgentId: "agent-001" },
preflightSameAgentDuplicate: true,
validateDuplicateCanonical: validate,
} as TaskCreateInput & { preflightSameAgentDuplicate: boolean; validateDuplicateCanonical: (task: Task) => Promise<void> }))
.rejects.toThrow("pre-existing task is not linked to this feature");
expect(validate).toHaveBeenCalledWith(canonical);
expect(store.createTask).not.toHaveBeenCalled();
});
it("does not select an archived same-agent task as a defined-feature bootstrap canonical", async () => {
const archived = {
id: "FN-archived", title: "Bootstrap feature", description: "Bootstrap the hand-authored feature",
sourceAgentId: "agent-001", dependencies: [], column: "archived" as const, steps: [], currentStep: 0,
log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} as Task;
const store = createMockTaskStore({ listTasks: vi.fn().mockResolvedValue([archived]) });
const validate = vi.fn().mockResolvedValue(undefined);
const result = await createAgentTask(store, {
title: "Bootstrap feature",
description: "Bootstrap the hand-authored feature",
source: { sourceType: "api", sourceAgentId: "agent-001" },
preflightSameAgentDuplicate: true,
validateDuplicateCanonical: validate,
} as TaskCreateInput & { preflightSameAgentDuplicate: boolean; validateDuplicateCanonical: (task: Task) => Promise<void> });
/* FNXC:MissionAdmission 2026-07-23-21:10: archived tasks are not live bootstrap canonicals and must not block a valid first task. */
expect(result.wasDuplicate).toBe(false);
expect(validate).not.toHaveBeenCalled();
expect(store.createTask).toHaveBeenCalledOnce();
});
it("serializes three concurrent paraphrased creates from one parent", async () => {
const tasks: Task[] = [];
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks);
@@ -592,7 +728,11 @@ describe("createDelegateTaskTool", () => {
return canonical;
});
const result = await createAgentTask(taskStore, { description: "Add new support" }, { sourceTaskId: "fn-parent" });
const validateDuplicateCanonical = vi.fn().mockResolvedValue(undefined);
const result = await createAgentTask(taskStore, {
description: "Add new support",
validateDuplicateCanonical,
} as TaskCreateInput & { validateDuplicateCanonical: (task: Task) => Promise<void> }, { sourceTaskId: "fn-parent" });
expect(taskStore.findRecentTasksBySourceParentTaskId).toHaveBeenCalledWith("FN-PARENT");
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
@@ -600,6 +740,8 @@ describe("createDelegateTaskTool", () => {
proposalClaimId: expect.stringMatching(/^agent-parent-intent:FN-PARENT:/),
}), expect.anything());
expect(result).toMatchObject({ task: canonical, wasDuplicate: true });
/* FNXC:MissionAdmission 2026-07-23-17:20: proposal-claim reuse must validate the final canonical, not only pre-create duplicate probes. */
expect(validateDuplicateCanonical).toHaveBeenCalledWith(canonical);
});
it("carries delegation routing onto the reconcile canonical task", async () => {
@@ -626,17 +768,21 @@ describe("createDelegateTaskTool", () => {
id === "FN-old" ? moved : { ...created, id, column },
);
const validateDuplicateCanonical = vi.fn().mockResolvedValue(undefined);
const result = await createAgentTask(taskStore, {
description: "Write tests",
mission_lineage: APPROVED_LINEAGE,
column: "todo",
assignedAgentId: "agent-002",
});
validateDuplicateCanonical,
} as TaskCreateInput & { validateDuplicateCanonical: (task: Task) => Promise<void> });
expect(result.wasDuplicate).toBe(true);
expect(result.task).toBe(moved);
expect(taskStore.updateTask).toHaveBeenCalledWith("FN-old", { assignedAgentId: "agent-002" });
expect(taskStore.moveTask).toHaveBeenCalledWith("FN-old", "todo");
/* FNXC:MissionAdmission 2026-07-23-17:20: post-create archival reconciliation must validate its returned canonical before duplicate success. */
expect(validateDuplicateCanonical).toHaveBeenCalledWith(moved);
});
it("returns success message with task ID and agent name", async () => {

View File

@@ -13,7 +13,7 @@ import { createHash, randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import * as fusionCore from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode, IdeationCandidate, MissionWithHierarchy } from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode, IdeationCandidate, MissionWithHierarchy, DbTransaction } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { computeCrossParentDiagnosticClaim, computeCrossParentDiagnosticClaimId, computeParentIntentClaimId, DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, findSameAgentDuplicates, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
@@ -979,6 +979,8 @@ type MissionLineageReference = {
missionId: string;
sliceId: string;
featureId: string;
/** Defined features are admitted only to atomically claim their first task. */
bootstrapDefinedFeature?: boolean;
};
/**
@@ -1040,14 +1042,108 @@ async function resolveApprovedMissionLineage(
const approval = fusionCore.evaluateMissionLineageApproval({
feature, slice, milestone, mission, task: {}, planApprovalRequired: false,
});
if (!approval.approved) return { error: `Mission lineage is not approved (${approval.reason}); no task was created.` };
/*
FNXC:MissionAdmission 2026-07-23-12:00:
A hand-authored defined Feature has no first task to link, so scheduler-only
approval would dead-end task creation. Admit it solely as a bootstrap claim;
symbol-lock admission remains triaged/in-progress in the core predicate.
*/
if (!approval.approved) {
if (approval.reason === "feature-not-implementable" && feature.status === "defined" && !feature.taskId) {
return { missionId: mission.id, sliceId: slice.id, featureId: feature.id, bootstrapDefinedFeature: true };
}
return { error: `Mission lineage is not approved (${approval.reason}); no task was created.` };
}
return { missionId: mission.id, sliceId: slice.id, featureId: feature.id };
}
type DefinedFeatureBootstrapStore = {
claimDefinedFeatureTaskInTransaction: (tx: DbTransaction, input: { featureId: string; taskId: string; missionId: string; sliceId: string }) => Promise<unknown>;
claimDefinedFeatureTask: (input: { featureId: string; taskId: string; missionId: string; sliceId: string }) => Promise<unknown>;
archiveDefinedFeatureBootstrapDuplicate: (input: { featureId: string; taskId: string; duplicateTaskId: string }) => Promise<void>;
};
type AgentTaskInputWithBootstrap = TaskCreateInput & {
afterTaskInsert?: (tx: DbTransaction, task: Task) => Promise<void>;
validateDuplicateCanonical?: (task: Task) => Promise<void>;
skipSameAgentDuplicateIntake?: boolean;
preflightSameAgentDuplicate?: boolean;
reconcileCreatedDuplicate?: (duplicate: Task, created: Task) => Promise<void>;
};
function definedFeatureBootstrapInput(store: TaskStore, lineage: MissionLineageReference | null): Pick<AgentTaskInputWithBootstrap, "afterTaskInsert" | "validateDuplicateCanonical" | "skipSameAgentDuplicateIntake" | "preflightSameAgentDuplicate" | "reconcileCreatedDuplicate"> {
if (!lineage?.bootstrapDefinedFeature) return {};
const missionStore = store.getMissionStore() as Partial<DefinedFeatureBootstrapStore>;
if (!missionStore.claimDefinedFeatureTaskInTransaction || !missionStore.claimDefinedFeatureTask || !missionStore.archiveDefinedFeatureBootstrapDuplicate) {
throw new Error("Defined-feature bootstrap requires the PostgreSQL mission store; no task was created.");
}
const claim = (taskId: string) => ({ featureId: lineage.featureId, taskId, missionId: lineage.missionId, sliceId: lineage.sliceId });
return {
/*
FNXC:MissionAdmission 2026-07-23-15:30:
The first defined-feature task and feature promotion are one PostgreSQL
transaction. Do not replace this hook with create-then-link compensation:
a failed claim must roll back the task row before any task is observable.
*/
afterTaskInsert: async (tx, task) => { await missionStore.claimDefinedFeatureTaskInTransaction!(tx, claim(task.id)); },
validateDuplicateCanonical: async (task) => { await missionStore.claimDefinedFeatureTask!(claim(task.id)); },
/*
FNXC:MissionAdmission 2026-07-23-20:00:
The ordinary same-agent intake runs after task-row commit and could archive
feature.taskId. Suppress only that path; deterministic reconciliation below
retains the claimed task and atomically archives a late competing duplicate.
*/
skipSameAgentDuplicateIntake: true,
preflightSameAgentDuplicate: true,
reconcileCreatedDuplicate: async (duplicate, created) => {
await missionStore.archiveDefinedFeatureBootstrapDuplicate!({
featureId: lineage.featureId,
taskId: created.id,
duplicateTaskId: duplicate.id,
});
},
};
}
/*
FNXC:AgentRouting 2026-07-29-00:00:
FN-8207 requires deterministic-duplicate canonical tasks to honor an explicit delegate's owner and todo-column request. Carry both mutations in the engine task-creation seam so every canonical return path is truthful without changing the shared core duplicate-guard API.
*/
async function findDefinedFeatureBootstrapDuplicate(
store: TaskStore,
input: TaskCreateInput,
sourceAgentId: string | undefined,
sourceParentTaskId: string | undefined,
): Promise<Task | undefined> {
if (!sourceAgentId && !sourceParentTaskId) return undefined;
const candidates = await store.listTasks({ slim: true, includeArchived: true, includeDeleted: true });
const byId = new Map(candidates.map((task) => [task.id, task]));
const matches = findSameAgentDuplicates({
title: input.title,
description: input.description,
sourceParentTaskId,
}, candidates.flatMap((task) => {
const createdAt = Date.parse(task.createdAt);
/*
FNXC:MissionAdmission 2026-07-23-21:10:
Defined-feature retry preflight follows the normal duplicate guard's live
task boundary. An archived sibling cannot be a bootstrap canonical because
claimDefinedFeatureTask rejects non-live task rows.
*/
if (Number.isNaN(createdAt) || task.deletedAt || task.column === "archived") return [];
return [{
id: task.id,
title: task.title ?? "",
description: task.description,
column: task.column,
createdAt,
sourceAgentId: task.sourceAgentId ?? null,
sourceParentTaskId: task.sourceParentTaskId ?? null,
}];
}), { sourceAgentId: sourceAgentId ?? null });
return matches[0] ? byId.get(matches[0].id) : undefined;
}
async function carryCanonicalTaskRouting(
store: TaskStore,
canonical: Task,
@@ -1071,6 +1167,7 @@ export async function createAgentTask(
input: TaskCreateInput,
options?: AgentTaskCreationOptions,
): Promise<{ task: Awaited<ReturnType<TaskStore["createTask"]>>; wasDuplicate: boolean }> {
const validateDuplicateCanonical = (input as AgentTaskInputWithBootstrap).validateDuplicateCanonical;
const settings = typeof (store as { getSettings?: unknown }).getSettings === "function"
? await store.getSettings()
: {} as Settings;
@@ -1106,6 +1203,7 @@ export async function createAgentTask(
try {
if (guard.action === "duplicate" && guard.existing) {
await validateDuplicateCanonical?.(guard.existing);
return {
task: await carryCanonicalTaskRouting(store, guard.existing, input),
wasDuplicate: true,
@@ -1130,6 +1228,7 @@ export async function createAgentTask(
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
const canonical = candidates[0];
if (canonical) {
await validateDuplicateCanonical?.(canonical);
return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true };
}
} catch (error) {
@@ -1161,6 +1260,7 @@ export async function createAgentTask(
const match = matches.find((candidate) => !acknowledged.has(candidate.id));
const canonical = match ? candidates.find((candidate) => candidate.id === match.id) : undefined;
if (canonical) {
await validateDuplicateCanonical?.(canonical);
return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true };
}
} catch (error) {
@@ -1172,6 +1272,21 @@ export async function createAgentTask(
}
}
/*
FNXC:MissionAdmission 2026-07-23-20:00:
Probe same-agent duplicates before a defined Feature is claimed. The generic
intake probe happens after commit and can archive feature.taskId; an existing
canonical must already belong to this feature or creation fails with no new
task, rather than silently repurposing unrelated work.
*/
if ((input as AgentTaskInputWithBootstrap).preflightSameAgentDuplicate && validateDuplicateCanonical) {
const duplicate = await findDefinedFeatureBootstrapDuplicate(store, input, sourceAgentId, sourceParentTaskId);
if (duplicate) {
await validateDuplicateCanonical(duplicate);
return { task: await carryCanonicalTaskRouting(store, duplicate, input), wasDuplicate: true };
}
}
const sourceMetadata = {
...(effectiveSource?.sourceMetadata ?? {}),
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
@@ -1220,21 +1335,35 @@ export async function createAgentTask(
onProposalClaimConflict: () => { proposalClaimConflict = true; },
});
const reconcileCreatedDuplicate = (input as AgentTaskInputWithBootstrap).reconcileCreatedDuplicate;
const reconcile = await reconcileDeterministicDuplicate(store, {
createdTask,
fingerprint: guard.fingerprint,
sourceParentTaskId,
logger: log,
onDuplicate: reconcileCreatedDuplicate
? async (duplicate) => {
await reconcileCreatedDuplicate(duplicate, createdTask);
return "keep-created";
}
: undefined,
});
return {
task: proposalClaimConflict
? await carryCanonicalTaskRouting(store, createdTask, input)
: reconcile.outcome === "archived"
? await carryCanonicalTaskRouting(store, reconcile.canonical, input)
: reconcile.canonical,
wasDuplicate: proposalClaimConflict || reconcile.outcome === "archived",
};
const wasDuplicate = proposalClaimConflict || reconcile.outcome === "archived" || reconcile.outcome === "kept-duplicate";
const canonical = proposalClaimConflict
? await carryCanonicalTaskRouting(store, createdTask, input)
: reconcile.outcome === "archived"
? await carryCanonicalTaskRouting(store, reconcile.canonical, input)
: reconcile.canonical;
/*
FNXC:MissionAdmission 2026-07-23-17:20:
A proposal-claim race and post-create reconciliation both select an existing
canonical after createTask returns. Revalidate that canonical before reporting
duplicate success so a defined feature cannot remain unlinked or claim an
archived/unrelated loser.
*/
if (wasDuplicate) await validateDuplicateCanonical?.(canonical);
return { task: canonical, wasDuplicate };
} finally {
guard.releaseLock();
}
@@ -1334,6 +1463,7 @@ export function createTaskCreateTool(
priority: params.priority,
...(workflowId ? { workflowId } : {}),
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
...definedFeatureBootstrapInput(store, lineage),
source: {
sourceType: provenance?.sourceType ?? "api",
sourceAgentId: provenance?.sourceAgentId,
@@ -4672,6 +4802,7 @@ export function createDelegateTaskTool(
assignedAgentId: params.agent_id,
...(workflowId ? { workflowId } : {}),
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
...definedFeatureBootstrapInput(taskStore, lineage),
source: {
sourceType: "api",
sourceParentTaskId: options?.sourceTaskId,