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`). > 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 ## 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: 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 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 ## 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. 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` - Mission: `active`
- Milestone: `active` - Milestone: `active`
- Slice: `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. 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 ### 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 { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
import { eq, sql } from "drizzle-orm"; 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 { import {
pgDescribe, pgDescribe,
@@ -244,6 +247,122 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(unlinked.status).toBe("defined"); 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: 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. 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; return outcome.feature;
} }
async linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> { /**
const feature = await getFeature(this.db, featureId); * Atomically claim a hand-authored defined feature for a task already inserted
if (!feature) throw new Error(`Feature ${featureId} not found`); * in the caller's transaction. This is intentionally narrower than
const liveTask = await getLiveTaskById(this.db, taskId); * linkFeatureToTask(): existing manual links may establish task lineage,
if (!liveTask) { * whereas a duplicate bootstrap canonical must already prove this lineage.
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.`, 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 shouldTransitionLoop = !feature.loopState || feature.loopState === "idle";
const loopStateUpdates: Partial<MissionFeature> = shouldTransitionLoop const updated: MissionFeature = {
? { loopState: "implementing", implementationAttemptCount: 1 } ...feature,
: {}; taskId: input.taskId,
const updated = await this.updateFeature(featureId, { taskId, status: "triaged", ...loopStateUpdates }); status: "triaged",
await setTaskMissionLinkage(this.db, taskId, linkage.missionId, linkage.sliceId); ...(shouldTransitionLoop ? { loopState: "implementing", implementationAttemptCount: 1 } : {}),
await this.recomputeSliceStatus(updated.sliceId); updatedAt: now,
this.emit("feature:linked", { feature: updated, taskId }); };
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; 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> { async unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
const feature = await getFeature(this.db, featureId); const feature = await getFeature(this.db, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`); if (!feature) throw new Error(`Feature ${featureId} not found`);

View File

@@ -139,8 +139,10 @@ export async function reconcileDeterministicDuplicate(
windowMs?: number; windowMs?: number;
sourceParentTaskId?: string | null; sourceParentTaskId?: string | null;
logger?: { warn(msg: string, data?: Record<string, unknown>): void }; 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) { if (!args.fingerprint) {
return { outcome: "kept", canonical: args.createdTask }; return { outcome: "kept", canonical: args.createdTask };
} }
@@ -160,6 +162,16 @@ export async function reconcileDeterministicDuplicate(
return { outcome: "kept", canonical: args.createdTask }; 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, { await store.updateTask(args.createdTask.id, {
sourceMetadataPatch: { sourceMetadataPatch: {
contentFingerprint: args.fingerprint, 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); const linkage = this.resolveTaskLinkage(feature.sliceId);
// When first linking (loopState is idle or falsy), transition to implementing // When first linking (loopState is idle or falsy), transition to implementing

View File

@@ -8,9 +8,10 @@
*/ */
import {TaskStore, storeLog} from "../store.js"; import {TaskStore, storeLog} from "../store.js";
import {InvalidFileScopeError, SelfDefeatingDependencyError, detectSelfDefeatingDependency, TombstonedTaskResurrectionError} from "./errors.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 {join} from "node:path";
import {existsSync} from "node:fs"; import {existsSync} from "node:fs";
import {randomUUID} from "node:crypto";
import type {Task, TaskCreateInput, Settings} from "../types.js"; import type {Task, TaskCreateInput, Settings} from "../types.js";
import "../builtin-traits.js"; import "../builtin-traits.js";
import {applyReviewLevelPreset} from "../review-level-preset.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 {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js";
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js"; import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js";
import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.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 { function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
/* /*
@@ -371,49 +383,44 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
} }
const dir = store.taskDir(id); 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. FNXC:MissionAdmission 2026-07-23-17:10:
// A duplicate-ID collision raises a unique_violation (23505) which we Materialize task files before the transaction that inserts and claims a
// catch and surface as "Task ID already exists" (matching the SQLite path). defined feature. Filesystem writes cannot join PostgreSQL; this ordering
const context = store.createTaskPersistSerializationContext(task); 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 { 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: FNXC:MissionAdmission 2026-07-23-19:00:
Proposal creation retries can race after a creation lease is released while PostgreSQL ID/proposal collisions are discovered at row insert, while a
the original creator is still inserting. Both attempts deliberately use the defined-feature bootstrap needs all task artifacts to be writable before
same stable proposalClaimId, so the partial unique index is the at-most-once its transaction can commit. Materialize into a unique staging directory;
authority. A 23505 for that key returns the committed winner instead of only the successful insert atomically promotes it to the task directory.
treating it as an ID collision; no loser may continue into task-file or A losing proposal therefore cannot overwrite its winner's task files.
workflow materialization. Other unique violations remain task-ID errors.
*/ */
if (input.proposalClaimId && isTaskIdConflictError(error)) { await mkdir(stagingDir, { recursive: true });
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId); ownsStagingDirectory = true;
if (existing) {
options?.onProposalClaimConflict?.(existing);
return existing;
}
}
if (isTaskIdConflictError(error)) {
throw new Error(`Task ID already exists: ${task.id}`);
}
throw error;
}
// 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. // Write task.json for backward compatibility and debugging.
if (store.isWatching) store.taskCache.set(id, { ...task }); 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). // 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); throw new InvalidFileScopeError(id, validation.invalid);
} }
} }
await mkdir(dir, { recursive: true }); await writeFile(join(stagingDir, "PROMPT.md"), prompt);
await writeFile(join(dir, "PROMPT.md"), prompt);
} catch (error) { } catch (error) {
// Rollback: soft-delete the inserted row and remove the directory. await cleanupPreparedTaskFiles();
await softDeleteTaskRowAsync(layer, id, new Date().toISOString()); if ((error as NodeJS.ErrnoException).code === "EEXIST") {
if (store.isWatching) store.taskCache.delete(id); throw new Error(`Task ID already exists: ${task.id}`);
if (existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
} }
throw error; throw error;
} }
// Auto-archive dedup (best-effort, same as SQLite path but using async reads). // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:30:
await store._maybeAutoArchiveSameAgentDuplicateBackend(task, input); // 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]); store.emitTaskLifecycleEventSafely("task:created", [task]);
if (options?.invokeTaskCreatedHook !== false) { if (options?.invokeTaskCreatedHook !== false) {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; 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"; import { createAgentTask, createListAgentsTool, createDelegateTaskTool, createTaskCreateTool } from "../agent-tools.js";
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore { function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
@@ -553,6 +553,142 @@ describe("createDelegateTaskTool", () => {
expect(createInput.sliceId).toBeUndefined(); 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 () => { it("serializes three concurrent paraphrased creates from one parent", async () => {
const tasks: Task[] = []; const tasks: Task[] = [];
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks); vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks);
@@ -592,7 +728,11 @@ describe("createDelegateTaskTool", () => {
return canonical; 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.findRecentTasksBySourceParentTaskId).toHaveBeenCalledWith("FN-PARENT");
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({ expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
@@ -600,6 +740,8 @@ describe("createDelegateTaskTool", () => {
proposalClaimId: expect.stringMatching(/^agent-parent-intent:FN-PARENT:/), proposalClaimId: expect.stringMatching(/^agent-parent-intent:FN-PARENT:/),
}), expect.anything()); }), expect.anything());
expect(result).toMatchObject({ task: canonical, wasDuplicate: true }); 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 () => { it("carries delegation routing onto the reconcile canonical task", async () => {
@@ -626,17 +768,21 @@ describe("createDelegateTaskTool", () => {
id === "FN-old" ? moved : { ...created, id, column }, id === "FN-old" ? moved : { ...created, id, column },
); );
const validateDuplicateCanonical = vi.fn().mockResolvedValue(undefined);
const result = await createAgentTask(taskStore, { const result = await createAgentTask(taskStore, {
description: "Write tests", description: "Write tests",
mission_lineage: APPROVED_LINEAGE, mission_lineage: APPROVED_LINEAGE,
column: "todo", column: "todo",
assignedAgentId: "agent-002", assignedAgentId: "agent-002",
}); validateDuplicateCanonical,
} as TaskCreateInput & { validateDuplicateCanonical: (task: Task) => Promise<void> });
expect(result.wasDuplicate).toBe(true); expect(result.wasDuplicate).toBe(true);
expect(result.task).toBe(moved); expect(result.task).toBe(moved);
expect(taskStore.updateTask).toHaveBeenCalledWith("FN-old", { assignedAgentId: "agent-002" }); expect(taskStore.updateTask).toHaveBeenCalledWith("FN-old", { assignedAgentId: "agent-002" });
expect(taskStore.moveTask).toHaveBeenCalledWith("FN-old", "todo"); 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 () => { 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 { tmpdir } from "node:os";
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import * as fusionCore from "@fusion/core"; 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 { 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 { 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"; 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; missionId: string;
sliceId: string; sliceId: string;
featureId: 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({ const approval = fusionCore.evaluateMissionLineageApproval({
feature, slice, milestone, mission, task: {}, planApprovalRequired: false, 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 }; 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: 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. 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( async function carryCanonicalTaskRouting(
store: TaskStore, store: TaskStore,
canonical: Task, canonical: Task,
@@ -1071,6 +1167,7 @@ export async function createAgentTask(
input: TaskCreateInput, input: TaskCreateInput,
options?: AgentTaskCreationOptions, options?: AgentTaskCreationOptions,
): Promise<{ task: Awaited<ReturnType<TaskStore["createTask"]>>; wasDuplicate: boolean }> { ): Promise<{ task: Awaited<ReturnType<TaskStore["createTask"]>>; wasDuplicate: boolean }> {
const validateDuplicateCanonical = (input as AgentTaskInputWithBootstrap).validateDuplicateCanonical;
const settings = typeof (store as { getSettings?: unknown }).getSettings === "function" const settings = typeof (store as { getSettings?: unknown }).getSettings === "function"
? await store.getSettings() ? await store.getSettings()
: {} as Settings; : {} as Settings;
@@ -1106,6 +1203,7 @@ export async function createAgentTask(
try { try {
if (guard.action === "duplicate" && guard.existing) { if (guard.action === "duplicate" && guard.existing) {
await validateDuplicateCanonical?.(guard.existing);
return { return {
task: await carryCanonicalTaskRouting(store, guard.existing, input), task: await carryCanonicalTaskRouting(store, guard.existing, input),
wasDuplicate: true, wasDuplicate: true,
@@ -1130,6 +1228,7 @@ export async function createAgentTask(
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt)); .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
const canonical = candidates[0]; const canonical = candidates[0];
if (canonical) { if (canonical) {
await validateDuplicateCanonical?.(canonical);
return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true }; return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true };
} }
} catch (error) { } catch (error) {
@@ -1161,6 +1260,7 @@ export async function createAgentTask(
const match = matches.find((candidate) => !acknowledged.has(candidate.id)); const match = matches.find((candidate) => !acknowledged.has(candidate.id));
const canonical = match ? candidates.find((candidate) => candidate.id === match.id) : undefined; const canonical = match ? candidates.find((candidate) => candidate.id === match.id) : undefined;
if (canonical) { if (canonical) {
await validateDuplicateCanonical?.(canonical);
return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true }; return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true };
} }
} catch (error) { } 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 = { const sourceMetadata = {
...(effectiveSource?.sourceMetadata ?? {}), ...(effectiveSource?.sourceMetadata ?? {}),
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}), ...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
@@ -1220,21 +1335,35 @@ export async function createAgentTask(
onProposalClaimConflict: () => { proposalClaimConflict = true; }, onProposalClaimConflict: () => { proposalClaimConflict = true; },
}); });
const reconcileCreatedDuplicate = (input as AgentTaskInputWithBootstrap).reconcileCreatedDuplicate;
const reconcile = await reconcileDeterministicDuplicate(store, { const reconcile = await reconcileDeterministicDuplicate(store, {
createdTask, createdTask,
fingerprint: guard.fingerprint, fingerprint: guard.fingerprint,
sourceParentTaskId, sourceParentTaskId,
logger: log, logger: log,
onDuplicate: reconcileCreatedDuplicate
? async (duplicate) => {
await reconcileCreatedDuplicate(duplicate, createdTask);
return "keep-created";
}
: undefined,
}); });
return { const wasDuplicate = proposalClaimConflict || reconcile.outcome === "archived" || reconcile.outcome === "kept-duplicate";
task: proposalClaimConflict const canonical = proposalClaimConflict
? await carryCanonicalTaskRouting(store, createdTask, input) ? await carryCanonicalTaskRouting(store, createdTask, input)
: reconcile.outcome === "archived" : reconcile.outcome === "archived"
? await carryCanonicalTaskRouting(store, reconcile.canonical, input) ? await carryCanonicalTaskRouting(store, reconcile.canonical, input)
: reconcile.canonical, : reconcile.canonical;
wasDuplicate: proposalClaimConflict || reconcile.outcome === "archived", /*
}; 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 { } finally {
guard.releaseLock(); guard.releaseLock();
} }
@@ -1334,6 +1463,7 @@ export function createTaskCreateTool(
priority: params.priority, priority: params.priority,
...(workflowId ? { workflowId } : {}), ...(workflowId ? { workflowId } : {}),
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}), ...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
...definedFeatureBootstrapInput(store, lineage),
source: { source: {
sourceType: provenance?.sourceType ?? "api", sourceType: provenance?.sourceType ?? "api",
sourceAgentId: provenance?.sourceAgentId, sourceAgentId: provenance?.sourceAgentId,
@@ -4672,6 +4802,7 @@ export function createDelegateTaskTool(
assignedAgentId: params.agent_id, assignedAgentId: params.agent_id,
...(workflowId ? { workflowId } : {}), ...(workflowId ? { workflowId } : {}),
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}), ...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
...definedFeatureBootstrapInput(taskStore, lineage),
source: { source: {
sourceType: "api", sourceType: "api",
sourceParentTaskId: options?.sourceTaskId, sourceParentTaskId: options?.sourceTaskId,